mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-08-24 02:57:43 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4475e6d12 | ||
|
|
e3b2ea725a | ||
|
|
6966edbfff | ||
|
|
42d4b72dd6 | ||
|
|
15f585c938 | ||
|
|
663f724c98 |
@@ -7,7 +7,9 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
actions: read
|
||||||
contents: write
|
contents: write
|
||||||
|
issues: read
|
||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
env:
|
env:
|
||||||
@@ -698,6 +700,9 @@ jobs:
|
|||||||
- publish-flatpak-repo
|
- publish-flatpak-repo
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Checkout release source
|
||||||
|
uses: actions/checkout@v4.1.7
|
||||||
|
|
||||||
- name: Download Windows artifact
|
- name: Download Windows artifact
|
||||||
uses: actions/download-artifact@v4.1.3
|
uses: actions/download-artifact@v4.1.3
|
||||||
with:
|
with:
|
||||||
@@ -768,3 +773,45 @@ jobs:
|
|||||||
release-assets/macos/KST4Contest-${{ github.ref_name }}-macos-*.dmg,
|
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-en.pdf,
|
||||||
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
release-assets/docs/KST4Contest-${{ github.ref_name }}-manual-de.pdf
|
||||||
|
|
||||||
|
# The update feed is generated only after GitHub has published the
|
||||||
|
# release. Otherwise the Releases API cannot return the release notes
|
||||||
|
# belonging to the tag which triggered this workflow.
|
||||||
|
- name: Set up Node.js for website build
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: website/package-lock.json
|
||||||
|
|
||||||
|
- name: Build and validate website after release publication
|
||||||
|
working-directory: website
|
||||||
|
run: |
|
||||||
|
npm ci
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Verify Stable release in update feed
|
||||||
|
if: ${{ !startsWith(github.ref_name, 'beta-') }}
|
||||||
|
working-directory: website
|
||||||
|
run: npm run validate:version-info
|
||||||
|
env:
|
||||||
|
EXPECTED_STABLE_VERSION: ${{ github.ref_name }}
|
||||||
|
|
||||||
|
- name: Attach verified version info to tagged release
|
||||||
|
run: >-
|
||||||
|
gh release upload "${GITHUB_REF_NAME}"
|
||||||
|
website/_site/kst4ContestVersionInfo.xml
|
||||||
|
--clobber
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Upload verified website artifact
|
||||||
|
uses: actions/upload-artifact@v4.3.4
|
||||||
|
with:
|
||||||
|
name: kst4contest-website-${{ github.ref_name }}
|
||||||
|
path: website/_site/
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 14
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -107,7 +107,7 @@ Die Flugzeugdaten können direkt in Nachrichten eingefügt werden:
|
|||||||
- `FIRSTAP` → z. B. `a very big AP in 1 min`
|
- `FIRSTAP` → z. B. `a very big AP in 1 min`
|
||||||
- `SECONDAP` → z. B. `Next big AP in 9 min`
|
- `SECONDAP` → z. B. `Next big AP in 9 min`
|
||||||
|
|
||||||
Details: [Makros und Variablen](Makros-und-Variablen#variablen)
|
Details: [Makros und Variablen](de-Makros-und-Variablen#variablen)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -389,7 +389,7 @@ Die automatische Bandvorauswahl ist eine Herleitung aus vorhandenen Chatinformat
|
|||||||
|
|
||||||
Bedienung: [Stationsinfo-Panel](de-Benutzeroberflaeche#stationsinfo-panel-further-info)
|
Bedienung: [Stationsinfo-Panel](de-Benutzeroberflaeche#stationsinfo-panel-further-info)
|
||||||
|
|
||||||
Darstellung: [AP- und Sked-Timeline](#ap-und-sked-timeline)
|
Darstellung: [AP- und Sked-Timeline](#ap--und-sked-timeline)
|
||||||
|
|
||||||
Win-Test-Übergabe: [Log-Synchronisation – Win-Test](de-Log-Synchronisation#win-test)
|
Win-Test-Übergabe: [Log-Synchronisation – Win-Test](de-Log-Synchronisation#win-test)
|
||||||
|
|
||||||
|
|||||||
@@ -64,43 +64,7 @@ Der Wert sollte zum eigenen Stationsaufbau und zum vorgesehenen Contestbetrieb p
|
|||||||
|
|
||||||
### Streckenanalyse und Link-Budget
|
### Streckenanalyse und Link-Budget
|
||||||
|
|
||||||
Die Streckenanalyse der Stationskarte verwendet einige Angaben aus der Stationskonfiguration. Diese Werte beschreiben das eigene Stationssetup und – soweit keine individuellen Daten der Gegenstation vorliegen – ein angenommenes Setup der Gegenstation.
|
Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Geländeprofil und das Link-Budget zur ausgewählten Gegenstation zu berechnen. Die Angaben zum eigenen Stationsaufbau sollten deshalb möglichst realistisch sein. Bei der Gegenstation handelt es sich dagegen um globale Annahmen, solange keine genaueren Daten vorliegen.
|
||||||
|
|
||||||
Folgende Einstellungen werden berücksichtigt:
|
|
||||||
|
|
||||||
- **Own antenna height AGL [m]** gibt die Höhe der eigenen Antenne über dem lokalen Gelände an. Die Angabe bezieht sich auf *Above Ground Level* und nicht auf die Höhe über dem Meeresspiegel. KST4Contest addiert diesen Wert zur Geländehöhe am eigenen Standort.
|
|
||||||
- **Own TX power [W]** gibt die verwendete Sendeleistung der eigenen Station in Watt an.
|
|
||||||
- **Own ant. gain [dBi]** gibt den Antennengewinn der eigenen Station in dBi an.
|
|
||||||
- **DX OM TX power [W]** gibt die für die Gegenstation angenommene Sendeleistung in Watt an.
|
|
||||||
- **DX OM ant. gain [dBi]** gibt den für die Gegenstation angenommenen Antennengewinn in dBi an.
|
|
||||||
|
|
||||||
Für die Antennenhöhe der Gegenstation verwendet KST4Contest derzeit einen festen Wert von 10 m über dem lokalen Gelände. Die Leistungs- und Antennendaten der Gegenstation sind globale Annahmen. Sie ersetzen keine individuell bekannten Stationsdaten, ermöglichen aber eine einheitliche Abschätzung, wenn keine genaueren Informationen vorliegen.
|
|
||||||
|
|
||||||
Die Antennengewinne müssen in dBi angegeben werden. Falls ein Wert in dBd vorliegt, kann er näherungsweise wie folgt umgerechnet werden:
|
|
||||||
|
|
||||||
`dBi = dBd + 2.15`
|
|
||||||
|
|
||||||
Auch die aktuelle Antennenrichtung, die konfigurierte Strahlbreite, das maximale QRB und die für die eigene Station aktivierten Bänder beeinflussen die Darstellung oder Auswertung auf der Stationskarte. Die aktuelle QTF und die Strahlbreite bestimmen beispielsweise den eingezeichneten Antennensektor und die Hervorhebung von Stationen innerhalb dieses Bereichs.
|
|
||||||
|
|
||||||
Für die Berücksichtigung der Erdkrümmung verwendet die Streckenanalyse einen festen Faktor von `k = 4/3` für den effektiven Erdradius. Das ist eine übliche Näherung für eine durchschnittliche troposphärische Refraktion. Tatsächliche Ausbreitungsbedingungen können davon deutlich abweichen.
|
|
||||||
|
|
||||||
Das Link-Budget berücksichtigt unter anderem:
|
|
||||||
|
|
||||||
- die Entfernung zwischen beiden Stationen,
|
|
||||||
- die verwendete Frequenz,
|
|
||||||
- die konfigurierte Sendeleistung,
|
|
||||||
- die Antennengewinne,
|
|
||||||
- geschätzte Speiseleitungsverluste,
|
|
||||||
- den Freiraumverlust sowie
|
|
||||||
- eine grobe Zusatzdämpfung durch Hindernisse im Streckenprofil.
|
|
||||||
|
|
||||||
Die daraus berechnete Empfangsleistung und SSB- beziehungsweise CW-Marge sind technische Abschätzungen. Sie sollen dabei helfen, mögliche Verbindungen einzuordnen. Sie sind keine vollständige Feldstärkeprognose und können insbesondere aktuelle Wetterbedingungen, lokale Abschattungen, Mehrwegeausbreitung oder andere nicht bekannte Stationsparameter nicht vollständig berücksichtigen.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Streckenanalyse und Link-Budget
|
|
||||||
|
|
||||||
Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Geländeprofil und das Link-Budget zur ausgewählten Gegenstation zu berechnen.
|
|
||||||
|
|
||||||
| Einstellung | Verwendung |
|
| Einstellung | Verwendung |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -110,28 +74,34 @@ Die Stationskarte verwendet mehrere Werte aus dem Reiter **Station**, um das Gel
|
|||||||
| **DX OM TX power W** | Angenommene Sendeleistung der Gegenstation in Watt |
|
| **DX OM TX power W** | Angenommene Sendeleistung der Gegenstation in Watt |
|
||||||
| **DX OM ant. gain dBi** | Angenommener Antennengewinn der Gegenstation in dBi |
|
| **DX OM ant. gain dBi** | Angenommener Antennengewinn der Gegenstation in dBi |
|
||||||
|
|
||||||
**AGL** bedeutet „above ground level“. Trage hier nicht die Höhe über dem Meeresspiegel ein. Die Geländehöhe am eigenen Standort stammt bereits aus dem abgerufenen Höhenprofil; die konfigurierte Antennenhöhe wird zu diesem Wert addiert.
|
**AGL** bedeutet *Above Ground Level*. Trage hier nicht die Höhe über dem Meeresspiegel ein. Die Geländehöhe am eigenen Standort stammt bereits aus dem Höhenprofil; KST4Contest addiert die konfigurierte Antennenhöhe zu diesem Wert.
|
||||||
|
|
||||||
Für die Antennenhöhe der Gegenstation verwendet KST4Contest derzeit einen festen Standardwert von 10 Metern über Grund. Eine stationsbezogene Antennenhöhe wird im ON4KST-Chat nicht übertragen.
|
Für die Gegenstation verwendet KST4Contest derzeit eine feste Antennenhöhe von 10 Metern über dem lokalen Gelände. Sendeleistung und Antennengewinn der Gegenstation stammen aus den beiden **DX OM**-Feldern. Diese Werte sind bewusst nur Annahmen: Der ON4KST-Chat überträgt weder die tatsächliche Antennenhöhe noch die vollständigen Stationsdaten der Gegenstation.
|
||||||
|
|
||||||
Antennengewinne müssen in `dBi` eingetragen werden. Liegt ein Wert in `dBd` vor, gilt:
|
Antennengewinne müssen in `dBi` eingetragen werden. Liegt ein Wert in `dBd` vor, muss vor der Eingabe `2.15 dB` addiert werden:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
dBi = dBd + 2,15 dB
|
dBi = dBd + 2.15 dB
|
||||||
|
```
|
||||||
|
|
||||||
|
Die aktuelle QTF, der konfigurierte Antennen-Öffnungswinkel und das Standard-Maximum-QRB beeinflussen die Darstellung der Stationskarte. Die für die eigene Station aktivierten Bänder und die für die Gegenstation hergeleitete Frequenz wirken sich zusätzlich auf die Auswahl der Analysefrequenz aus.
|
||||||
|
|
||||||
|
Für das Geländeprofil berücksichtigt KST4Contest die Antennenhöhen, das Höhenmodell und die Erdkrümmung mit einem festen effektiven Erdradiusfaktor von `k = 4/3`. Das Link-Budget verwendet außerdem:
|
||||||
|
|
||||||
## Server-Einstellungen (ab v1.31)
|
- die Entfernung und die aktuelle Analysefrequenz,
|
||||||
|
- die Sendeleistungen und Antennengewinne beider Stationen,
|
||||||
|
- frequenzabhängig geschätzte Speiseleitungsverluste,
|
||||||
|
- die Freiraumdämpfung und
|
||||||
|
- eine grobe zusätzliche Dämpfung durch das maßgebliche Hindernis im Geländeprofil.
|
||||||
|
|
||||||
Der Chat-Server-DNS und -Port sind in den Preferences konfigurierbar:
|
Die Berechnung erfolgt für beide Übertragungsrichtungen. Für die gemeinsame SSB- beziehungsweise CW-Marge ist die ungünstigere Richtung maßgeblich. Zu optimistische Leistungs- oder Antennenwerte verbessern daher zwar das angezeigte Ergebnis, nicht aber den realen Funkweg.
|
||||||
|
|
||||||
- **Server-DNS**: Standard `www.on4kst.org` (ab v1.31 geändert von `www.on4kst.info`).
|
Die Werte bleiben technische Abschätzungen. Aktuelle Ausbreitungsbedingungen, lokale Abschattungen, Bewuchs, Gebäude, Störungen und nicht bekannte Stationsparameter können das tatsächliche Ergebnis deutlich verändern. Bedienung, Frequenzauswahl und Grenzen der Berechnung sind unter [Stationskarte und Streckenanalyse](de-Funktionen#stationskarte-und-streckenanalyse-ab-v141) beschrieben.
|
||||||
- **Port**: Standardport des ON4KST-Servers.
|
|
||||||
|
|
||||||
Eine Änderung ist nur notwendig, wenn der Server umzieht oder ein alternativer Endpunkt genutzt wird.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Log-Sync-Einstellungen
|
## Log-Sync-Einstellungen
|
||||||
|
|
||||||
Drei Methoden stehen zur Verfügung, um gearbeitete Stationen automatisch zu markieren. Details: [Log-Synchronisation](de-Log-Synchronisation).
|
Drei Methoden stehen zur Verfügung, um gearbeitete Stationen automatisch zu markieren. Details: [Log-Synchronisation](de-Log-Synchronisation).
|
||||||
@@ -283,7 +253,7 @@ Doppelte oder syntaktisch ungültige Rufzeichen werden nicht übernommen. Die Li
|
|||||||
|
|
||||||
## Shortcut Settings (Schnellzugriff-Schaltflächen)
|
## Shortcut Settings (Schnellzugriff-Schaltflächen)
|
||||||
|
|
||||||
Konfiguration von Schnellzugriff-Schaltflächen, die direkt im Hauptfenster erscheinen. Ein Klick auf eine Schaltfläche fügt den konfigurierten Text in das Sendfeld ein. Alle [Variablen](Makros-und-Variablen#variablen) können verwendet werden.
|
Konfiguration von Schnellzugriff-Schaltflächen, die direkt im Hauptfenster erscheinen. Ein Klick auf eine Schaltfläche fügt den konfigurierten Text in das Sendfeld ein. Alle [Variablen](de-Makros-und-Variablen#variablen) können verwendet werden.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Aircraft data can be inserted directly into messages:
|
|||||||
- `FIRSTAP` → e.g. `a very big AP in 1 min`
|
- `FIRSTAP` → e.g. `a very big AP in 1 min`
|
||||||
- `SECONDAP` → e.g. `Next big AP in 9 min`
|
- `SECONDAP` → e.g. `Next big AP in 9 min`
|
||||||
|
|
||||||
Details: [Macros and Variables](Macros-and-Variables#variables)
|
Details: [Macros and Variables](en-Macros-and-Variables#variables)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ After a change, click **Save Settings** and restart KST4Contest. Band columns an
|
|||||||
|
|
||||||
### Antenna Beamwidth
|
### Antenna Beamwidth
|
||||||
|
|
||||||
Enter a realistic value for your antenna's beamwidth (in degrees). This value is used for the [Sked Direction Highlighting](Features#sked-direction-highlighting). A test value of 50° has proven effective; DM5M uses quads with 69°.
|
Enter a realistic value for your antenna's beamwidth (in degrees). This value is used for the [Sked Direction Highlighting](en-Features#sked-direction-highlighting). A test value of 50° has proven effective; DM5M uses quads with 69°.
|
||||||
|
|
||||||
> **Do not** enter fantasy values – the direction calculations will become useless.
|
> **Do not** enter fantasy values – the direction calculations will become useless.
|
||||||
|
|
||||||
@@ -50,66 +50,82 @@ Maximum distance (in km) for which direction warnings should be triggered. A rea
|
|||||||
|
|
||||||
### Path Analysis and Link Budget
|
### Path Analysis and Link Budget
|
||||||
|
|
||||||
The station map uses several values from the station settings for its path analysis. These values describe the local station setup and, where no individual information is available, an assumed setup for the remote station.
|
The station map uses several values from the **Station** tab to calculate the terrain profile and link budget for the selected remote station. Enter the local station data as realistically as possible. The remote-station values remain global assumptions unless more accurate information is available.
|
||||||
|
|
||||||
The following settings are used:
|
| Setting | Use |
|
||||||
|
|---|---|
|
||||||
|
| **Own antenna height AGL** | Height of the local antenna above the surrounding terrain in metres |
|
||||||
|
| **Own TX power W** | Local transmit power in watts |
|
||||||
|
| **Own ant. gain dBi** | Gain of the local antenna in dBi |
|
||||||
|
| **DX OM TX power W** | Assumed transmit power of the remote station in watts |
|
||||||
|
| **DX OM ant. gain dBi** | Assumed antenna gain of the remote station in dBi |
|
||||||
|
|
||||||
- **Own antenna height AGL [m]** specifies the height of the local antenna above the surrounding terrain. The value is *Above Ground Level*, not height above sea level. KST4Contest adds it to the terrain elevation at the local station.
|
**AGL** means *Above Ground Level*. Do not enter the height above sea level. The terrain elevation at the local station already comes from the elevation profile; KST4Contest adds the configured antenna height to this value.
|
||||||
- **Own TX power [W]** specifies the transmit power of the local station in watts.
|
|
||||||
- **Own ant. gain [dBi]** specifies the antenna gain of the local station in dBi.
|
|
||||||
- **DX OM TX power [W]** specifies the transmit power assumed for the remote station.
|
|
||||||
- **DX OM ant. gain [dBi]** specifies the antenna gain assumed for the remote station.
|
|
||||||
|
|
||||||
KST4Contest currently uses a fixed antenna height of 10 metres above the local terrain for the remote station. The remote power and antenna-gain values are global assumptions. They cannot replace individually known station data, but they provide a consistent basis when no better information is available.
|
For the remote station, KST4Contest currently uses a fixed antenna height of 10 metres above the local terrain. Its transmit power and antenna gain come from the two **DX OM** fields. These values are deliberately assumptions: ON4KST provides neither the actual antenna height nor the complete station data of the remote operator.
|
||||||
|
|
||||||
Antenna gains must be entered in dBi. If a value is specified in dBd, it can be converted approximately as follows:
|
Antenna gains must be entered in `dBi`. Add `2.15 dB` before entering a value specified in `dBd`:
|
||||||
|
|
||||||
`dBi = dBd + 2.15`
|
```text
|
||||||
|
dBi = dBd + 2.15 dB
|
||||||
|
```
|
||||||
|
|
||||||
The current antenna direction, configured beamwidth, maximum QRB and bands enabled for the local station also affect the map display or its assessment. The current QTF and beamwidth, for example, determine the displayed antenna sector and the highlighting of stations inside that sector.
|
The current QTF, configured antenna beamwidth and default maximum QRB affect the map display. The bands enabled for the local station and the frequency derived for the remote station also affect the selection of the analysis frequency.
|
||||||
|
|
||||||
The path analysis uses a fixed effective Earth-radius factor of `k = 4/3`. This is a common approximation for average tropospheric refraction. Actual propagation conditions can differ considerably.
|
For the terrain profile, KST4Contest uses the antenna heights, elevation model and Earth curvature with a fixed effective Earth-radius factor of `k = 4/3`. The link budget additionally includes:
|
||||||
|
|
||||||
The link budget takes into account:
|
- distance and the current analysis frequency,
|
||||||
|
- transmit powers and antenna gains in both directions,
|
||||||
- the distance between both stations,
|
- frequency-dependent estimated feeder losses,
|
||||||
- the analysis frequency,
|
|
||||||
- the configured transmit powers,
|
|
||||||
- the antenna gains,
|
|
||||||
- estimated feeder losses,
|
|
||||||
- free-space path loss, and
|
- free-space path loss, and
|
||||||
- a rough additional loss caused by obstructions in the terrain profile.
|
- a rough additional loss caused by the relevant obstruction in the terrain profile.
|
||||||
|
|
||||||
The resulting received power and SSB or CW margins are technical estimates. They help to classify a possible path, but they are not a complete field-strength prediction. Current weather conditions, local obstructions, multipath propagation and unknown station parameters cannot be represented completely.
|
The calculation is performed in both directions. The weaker direction determines the common SSB or CW margin. Optimistic power or antenna values therefore improve the displayed result, but they do not improve the real radio path.
|
||||||
|
|
||||||
|
The results remain engineering estimates. Current propagation conditions, local obstructions, vegetation, buildings, interference and unknown station parameters can change the actual result considerably. Operation, frequency selection and the limits of the calculation are described under [Station Map and Path Analysis](en-Features#station-map-and-path-analysis-from-v141).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Server Settings (from v1.31)
|
## Server Settings (from v1.31)
|
||||||
|
|
||||||
The chat server DNS and port are configurable in the Preferences:
|
The connection details for the ON4KST chat server are shown at the top of the **Station** tab. They normally do not need to be changed.
|
||||||
|
|
||||||
- **Server DNS**: Default `www.on4kst.org` (changed from `www.on4kst.info` in v1.31 hotfix).
|
| Setting | Meaning |
|
||||||
- **Port**: Default port of the ON4KST server.
|
|---|---|
|
||||||
|
| **ON4KST server** | DNS name or IP address of the chat server; the software default is `www.on4kst.org` |
|
||||||
|
| **Port** | TCP port of the chat server; the default is `23001` |
|
||||||
|
|
||||||
A change is only needed if the server moves or an alternative endpoint is used.
|
`www.on4kst.info` may also remain in an existing, working configuration. There is no reason to change it merely because the displayed name differs from the software default, provided that the chat connection remains reliable.
|
||||||
|
|
||||||
|
These values apply only to the outgoing ON4KST chat connection. They do not change the local DX cluster server or any connection to AirScout, PSTRotator or logging software.
|
||||||
|
|
||||||
|
The port must be between `1` and `65535`. An invalid entry is rejected and replaced with the last valid value. An incorrect server name or port, on the other hand, prevents KST4Contest from connecting to the chat.
|
||||||
|
|
||||||
|
Changing either value does not alter an existing TCP connection. Disconnect and reconnect the chat, or restart KST4Contest. Then use **Save Settings** so that the values are retained for the next program start.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Log Sync Settings
|
## Log Sync Settings
|
||||||
|
|
||||||
Three methods are available for automatically marking worked stations. Details: [Log Synchronisation](en-Log-Sync).
|
The **Log sync** tab selects the sources from which KST4Contest imports worked stations. The three input paths provide different levels of detail:
|
||||||
|
|
||||||
### Universal File Based Callsign Interpreter (Simplelogfile)
|

|
||||||
|
|
||||||
Interprets any log file using regex for callsign patterns. No band information is available. Suitable as a fallback or for log programs that are not directly supported.
|
| Input path | Data used | Result in KST4Contest |
|
||||||
|
|---|---|---|
|
||||||
|
| **Simplelogfile** | Callsigns read from a selected file | global Worked status, but no band or locator information |
|
||||||
|
| **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 |
|
||||||
|
|
||||||
### Network Listener for Logger's QSO UDP Broadcast
|
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.
|
||||||
|
|
||||||
**Recommended method.** KST4Contest listens for UDP packets sent by the logging software to the broadcast address when a QSO is saved. Stations are marked with band information. UDP port: default **12060**. (Used by UCXLog, N1MM+, QARTest, DXLog.net, etc.).
|
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 Network Listener (Additional UDP Listener)
|
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.
|
||||||
|
|
||||||
A dedicated network listener for Win-Test. KST4Contest receives and processes Win-Test-specific UDP packets (including sked handovers) on the configured port.
|
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.
|
||||||
|
|
||||||
|
Configuration of the individual logging applications, band and locator handling, and the Win-Test sked handover are described under [Log Synchronisation](en-Log-Sync).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -188,7 +204,7 @@ The two options serve different purposes:
|
|||||||
|
|
||||||
The Priority Boost is only one factor in the complete calculation. Distance, antenna direction, recent activity, AirScout data, skeds and negative hints may still change the final order. Enabling the option therefore guarantees neither a particular score nor a particular position in the priority list.
|
The Priority Boost is only one factor in the complete calculation. Distance, antenna direction, recent activity, AirScout data, skeds and negative hints may still change the final order. Enabling the option therefore guarantees neither a particular score nor a particular position in the priority list.
|
||||||
|
|
||||||
The other score weights currently have no separate user-interface controls. Several existing settings nevertheless provide input data for the calculation, particularly the [enabled bands](#enabled-bands), [antenna beamwidth](#antenna-beamwidth), [default maximum QRB](#default-maximum-qrb) and [AirScout settings](#airscout-settings).
|
The other score weights currently have no separate user-interface controls. Several existing settings nevertheless provide input data for the calculation, particularly the [active bands](#active-bands), [antenna beamwidth](#antenna-beamwidth), [default maximum QRB](#default-maximum-qrb) and [AirScout settings](#airscout-settings).
|
||||||
|
|
||||||
The complete calculation is described under [Priority Score and Priority List](en-Features#priority-score-and-priority-list-from-v140).
|
The complete calculation is described under [Priority Score and Priority List](en-Features#priority-score-and-priority-list-from-v140).
|
||||||
|
|
||||||
@@ -201,7 +217,7 @@ Further explanation: [Band Upgrade Hint after a Log Entry](en-Features#band-upgr
|
|||||||
|
|
||||||
## Shortcut Settings
|
## Shortcut Settings
|
||||||
|
|
||||||
Configuration of quick-access buttons that appear directly in the main window. Clicking a button inserts the configured text into the send field. All [variables](Macros-and-Variables#variables) can be used.
|
Configuration of quick-access buttons that appear directly in the main window. Clicking a button inserts the configured text into the send field. All [variables](en-Macros-and-Variables#variables) can be used.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ The calculation is based on the following logic:
|
|||||||
|
|
||||||
The calculation does not include topographic path calculations – this is a deliberate simplification. It may be added in a future version.
|
The calculation does not include topographic path calculations – this is a deliberate simplification. It may be added in a future version.
|
||||||
|
|
||||||
> Configuration: [Configuration – Antenna Beamwidth](Configuration#antenna-beamwidth)
|
> Configuration: [Configuration – Antenna Beamwidth](en-Configuration#antenna-beamwidth)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -443,7 +443,7 @@ In practical terms, the score does not replace the operator’s decision. It pre
|
|||||||
|
|
||||||
Related settings:
|
Related settings:
|
||||||
|
|
||||||
- [Enabled Bands](en-Configuration#enabled-bands)
|
- [Active Bands](en-Configuration#active-bands)
|
||||||
- [Antenna Beamwidth](en-Configuration#antenna-beamwidth)
|
- [Antenna Beamwidth](en-Configuration#antenna-beamwidth)
|
||||||
- [Default Maximum QRB](en-Configuration#default-maximum-qrb)
|
- [Default Maximum QRB](en-Configuration#default-maximum-qrb)
|
||||||
- [AirScout Settings](en-Configuration#airscout-settings)
|
- [AirScout Settings](en-Configuration#airscout-settings)
|
||||||
@@ -503,7 +503,7 @@ The timeline is a preview. AirScout data can change, and a stored sked guarantee
|
|||||||
|
|
||||||
## Interval Beacon
|
## Interval Beacon
|
||||||
|
|
||||||
Automatic CQ messages in the public channel at a configurable interval. Recommended: use the `MYQRG` variable so the current frequency is always accurate. Details: [Configuration – Beacon Settings](Configuration#beacon-settings).
|
Automatic CQ messages in the public channel at a configurable interval. Recommended: use the `MYQRG` variable so the current frequency is always accurate. Details: [Configuration – Beacon Settings](en-Configuration#beacon-settings).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.4 MiB |
+1013
File diff suppressed because it is too large
Load Diff
@@ -34,7 +34,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
import java.util.function.Consumer;
|
import java.util.function.Consumer;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1490,6 +1490,38 @@ private ObservableList<String>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses one unambiguous explicit QRG from the current station name as the
|
||||||
|
* initial compatibility frequency.
|
||||||
|
*
|
||||||
|
* <p>Multiple explicit QRGs are valid band evidence, but there is no safe
|
||||||
|
* 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(
|
||||||
|
ChatMember member
|
||||||
|
) {
|
||||||
|
if (member == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FrequencyTextParser.DetectedFrequency> detectedFrequencies =
|
||||||
|
FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
member.getName()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (detectedFrequencies.size() != 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
member.initializeFrequencyIfEmpty(
|
||||||
|
detectedFrequencies
|
||||||
|
.get(0)
|
||||||
|
.getFrequencyMHz()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds or replaces an active chat member in the worker-thread model and mirrors
|
* Adds or replaces an active chat member in the worker-thread model and mirrors
|
||||||
* that change to the JavaFX list. This is the only supported path for ON4KST
|
* that change to the JavaFX list. This is the only supported path for ON4KST
|
||||||
@@ -1501,6 +1533,8 @@ private ObservableList<String>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeFrequencyFromStationNameIfUnambiguous(member);
|
||||||
|
|
||||||
activeChatMembersByCallAndCategory.put(key, member);
|
activeChatMembersByCallAndCategory.put(key, member);
|
||||||
|
|
||||||
runOnFxThread(() -> {
|
runOnFxThread(() -> {
|
||||||
@@ -1683,6 +1717,9 @@ private ObservableList<String>
|
|||||||
}
|
}
|
||||||
|
|
||||||
activeMember.setName(updatedMember.getName());
|
activeMember.setName(updatedMember.getName());
|
||||||
|
initializeFrequencyFromStationNameIfUnambiguous(
|
||||||
|
activeMember
|
||||||
|
);
|
||||||
activeMember.setQra(updatedMember.getQra());
|
activeMember.setQra(updatedMember.getQra());
|
||||||
activeMember.setState(updatedMember.getState());
|
activeMember.setState(updatedMember.getState());
|
||||||
activeMember.setLastActivity(updatedMember.getLastActivity());
|
activeMember.setLastActivity(updatedMember.getLastActivity());
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import kst4contest.ApplicationConstants;
|
|||||||
import kst4contest.locatorUtils.DirectionUtils;
|
import kst4contest.locatorUtils.DirectionUtils;
|
||||||
import kst4contest.locatorUtils.Location;
|
import kst4contest.locatorUtils.Location;
|
||||||
import kst4contest.model.*;
|
import kst4contest.model.*;
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@@ -58,7 +59,7 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* would be converted into plausible but incorrect frequencies.
|
* would be converted into plausible but incorrect frequencies.
|
||||||
*/
|
*/
|
||||||
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
|
private static final Pattern SMART_FREQUENCY_PATTERN = Pattern.compile(
|
||||||
"(?<![\\d])(\\d{3,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
"(?<![\\d])(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
||||||
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
+ "|(?<![\\d])([.,]\\d{3}(?:[.,]\\d{1,3})?)(?![\\d])"
|
||||||
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
+ "|(?<=\\s|^)(\\d{3})(?=\\s|$)"
|
||||||
);
|
);
|
||||||
@@ -319,16 +320,28 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String reconstructed =
|
String reconstructed =
|
||||||
candidateBand.getPrefix() + "." + foundRaw;
|
candidateBand.getPrefix()
|
||||||
double candidateFrequency = Double.parseDouble(
|
+ "."
|
||||||
normalizeFrequencyString(reconstructed)
|
+ foundRaw;
|
||||||
|
|
||||||
|
FrequencyTextParser.DetectedFrequency detectedFrequency =
|
||||||
|
FrequencyTextParser.parseExplicitFrequency(
|
||||||
|
reconstructed
|
||||||
);
|
);
|
||||||
|
|
||||||
if (candidateBand.isPlausible(candidateFrequency)
|
if (detectedFrequency != null
|
||||||
|
&& detectedFrequency.getBand() == candidateBand
|
||||||
&& info.timestampEpoch > bestTimestamp) {
|
&& info.timestampEpoch > bestTimestamp) {
|
||||||
finalDetectedFrequency = candidateFrequency;
|
|
||||||
finalDetectedBand = candidateBand;
|
finalDetectedFrequency =
|
||||||
bestTimestamp = info.timestampEpoch;
|
detectedFrequency.getFrequencyMHz();
|
||||||
|
|
||||||
|
finalDetectedBand =
|
||||||
|
candidateBand;
|
||||||
|
|
||||||
|
bestTimestamp =
|
||||||
|
info.timestampEpoch;
|
||||||
|
|
||||||
}
|
}
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
// Try the next known band.
|
// Try the next known band.
|
||||||
@@ -357,27 +370,40 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String reconstructed =
|
String reconstructed =
|
||||||
fallbackBand.getPrefix() + "." + foundRaw;
|
fallbackBand.getPrefix()
|
||||||
double candidateFrequency = Double.parseDouble(
|
+ "."
|
||||||
normalizeFrequencyString(reconstructed)
|
+ foundRaw;
|
||||||
|
|
||||||
|
FrequencyTextParser.DetectedFrequency detectedFrequency =
|
||||||
|
FrequencyTextParser.parseExplicitFrequency(
|
||||||
|
reconstructed
|
||||||
);
|
);
|
||||||
|
|
||||||
if (fallbackBand.isPlausible(candidateFrequency)) {
|
if (detectedFrequency != null
|
||||||
finalDetectedFrequency = candidateFrequency;
|
&& detectedFrequency.getBand() == fallbackBand) {
|
||||||
finalDetectedBand = fallbackBand;
|
|
||||||
|
finalDetectedFrequency =
|
||||||
|
detectedFrequency.getFrequencyMHz();
|
||||||
|
|
||||||
|
finalDetectedBand =
|
||||||
|
fallbackBand;
|
||||||
}
|
}
|
||||||
} catch (NumberFormatException ignored) {
|
} catch (NumberFormatException ignored) {
|
||||||
// The matched value cannot be converted into a frequency.
|
// The matched value cannot be converted into a frequency.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
FrequencyTextParser.DetectedFrequency detectedFrequency =
|
||||||
finalDetectedFrequency = Double.parseDouble(
|
FrequencyTextParser.parseExplicitFrequency(
|
||||||
normalizeFrequencyString(foundRaw)
|
foundRaw
|
||||||
);
|
);
|
||||||
finalDetectedBand = Band.fromFrequency(finalDetectedFrequency);
|
|
||||||
} catch (NumberFormatException ignored) {
|
if (detectedFrequency != null) {
|
||||||
// Continue with the next possible match in the message.
|
finalDetectedFrequency =
|
||||||
|
detectedFrequency.getFrequencyMHz();
|
||||||
|
|
||||||
|
finalDetectedBand =
|
||||||
|
detectedFrequency.getBand();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,22 +488,22 @@ public class MessageBusManagementThread extends Thread {
|
|||||||
* Example: "144.210.10" -> "144.21010"
|
* Example: "144.210.10" -> "144.21010"
|
||||||
* Example: "144.210" -> "144.210"
|
* Example: "144.210" -> "144.210"
|
||||||
*/
|
*/
|
||||||
private String normalizeFrequencyString(String rawInput) {
|
// private String normalizeFrequencyString(String rawInput) {
|
||||||
// Input is already guaranteed to have only dots as separators (commas replaced earlier)
|
// // Input is already guaranteed to have only dots as separators (commas replaced earlier)
|
||||||
|
//
|
||||||
int firstDotIndex = rawInput.indexOf(".");
|
// int firstDotIndex = rawInput.indexOf(".");
|
||||||
|
//
|
||||||
if (firstDotIndex != -1) {
|
// if (firstDotIndex != -1) {
|
||||||
// Check if there are more dots after the first one
|
// // Check if there are more dots after the first one
|
||||||
String decimalPart = rawInput.substring(firstDotIndex + 1);
|
// String decimalPart = rawInput.substring(firstDotIndex + 1);
|
||||||
if (decimalPart.contains(".")) {
|
// if (decimalPart.contains(".")) {
|
||||||
// Remove all subsequent dots to make it a valid double
|
// // Remove all subsequent dots to make it a valid double
|
||||||
decimalPart = decimalPart.replace(".", "");
|
// decimalPart = decimalPart.replace(".", "");
|
||||||
return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
|
// return rawInput.substring(0, firstDotIndex) + "." + decimalPart;
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
return rawInput;
|
// return rawInput;
|
||||||
}
|
// }
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -113,12 +113,32 @@ public final class BandOpportunityResolver {
|
|||||||
return detectedBands;
|
return detectedBands;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Explicit band descriptions:
|
||||||
|
* 2m, 70cm, 23cm, 432, 1296, ...
|
||||||
|
*/
|
||||||
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
|
for (Map.Entry<Band, Pattern> entry : STATION_NAME_BAND_PATTERNS.entrySet()) {
|
||||||
if (entry.getValue().matcher(stationName).find()) {
|
if (entry.getValue().matcher(stationName).find()) {
|
||||||
detectedBands.add(entry.getKey());
|
detectedBands.add(entry.getKey());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Explicit full QRGs in name field:
|
||||||
|
* 432.357 -> 432 MHz
|
||||||
|
* 1296.210 -> 1296 MHz
|
||||||
|
* etc.
|
||||||
|
*/
|
||||||
|
for (FrequencyTextParser.DetectedFrequency detectedFrequency
|
||||||
|
: FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
stationName
|
||||||
|
)) {
|
||||||
|
|
||||||
|
detectedBands.add(
|
||||||
|
detectedFrequency.getBand()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return detectedBands;
|
return detectedBands;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package kst4contest.logic;
|
||||||
|
|
||||||
|
import kst4contest.model.Band;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
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>
|
||||||
|
*/
|
||||||
|
public final class FrequencyTextParser {
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Examples:
|
||||||
|
* 50.150
|
||||||
|
* 144.300
|
||||||
|
* 432,357
|
||||||
|
* 10368.100
|
||||||
|
* 144.300.03
|
||||||
|
*
|
||||||
|
* At least two digits are required before the decimal separator. This
|
||||||
|
* intentionally prevents "1.2" from being interpreted as a frequency.
|
||||||
|
*/
|
||||||
|
private static final Pattern EXPLICIT_FREQUENCY_PATTERN = Pattern.compile(
|
||||||
|
"(?<![A-Z0-9])"
|
||||||
|
+ "(\\d{2,5}[.,]\\d{1,3}(?:[.,]\\d{1,3})?)"
|
||||||
|
+ "(?!\\d)",
|
||||||
|
Pattern.CASE_INSENSITIVE
|
||||||
|
);
|
||||||
|
|
||||||
|
private FrequencyTextParser() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds all distinct explicit frequencies that fall into one of the bands
|
||||||
|
* supported by {@link Band}.
|
||||||
|
*/
|
||||||
|
public static List<DetectedFrequency> findExplicitFrequencies(String text) {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
Matcher matcher = EXPLICIT_FREQUENCY_PATTERN.matcher(text);
|
||||||
|
|
||||||
|
Map<String, DetectedFrequency> uniqueFrequencies =
|
||||||
|
new LinkedHashMap<>();
|
||||||
|
|
||||||
|
while (matcher.find()) {
|
||||||
|
DetectedFrequency detected =
|
||||||
|
parseExplicitFrequency(matcher.group(1));
|
||||||
|
|
||||||
|
if (detected == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
String uniqueKey =
|
||||||
|
detected.getBand().name()
|
||||||
|
+ "|"
|
||||||
|
+ Double.toString(
|
||||||
|
detected.getFrequencyMHz()
|
||||||
|
);
|
||||||
|
|
||||||
|
uniqueFrequencies.putIfAbsent(
|
||||||
|
uniqueKey,
|
||||||
|
detected
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return List.copyOf(
|
||||||
|
new ArrayList<>(uniqueFrequencies.values())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses one complete frequency.
|
||||||
|
*
|
||||||
|
* @return detected band/frequency or {@code null} when the value is invalid
|
||||||
|
* or outside the supported amateur bands
|
||||||
|
*/
|
||||||
|
public static DetectedFrequency parseExplicitFrequency(
|
||||||
|
String rawFrequency
|
||||||
|
) {
|
||||||
|
if (rawFrequency == null || rawFrequency.isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String normalized =
|
||||||
|
normalizeFrequencyString(
|
||||||
|
rawFrequency
|
||||||
|
.trim()
|
||||||
|
.replace(',', '.')
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
double frequencyMHz =
|
||||||
|
Double.parseDouble(normalized);
|
||||||
|
|
||||||
|
if (!Double.isFinite(frequencyMHz)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Band band =
|
||||||
|
Band.fromFrequency(frequencyMHz);
|
||||||
|
|
||||||
|
if (band == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DetectedFrequency(
|
||||||
|
band,
|
||||||
|
frequencyMHz,
|
||||||
|
rawFrequency.trim()
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (NumberFormatException ignored) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes KST-style frequency strings with an optional second decimal
|
||||||
|
* separator.
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* 144.300.03 -> 144.30003
|
||||||
|
* 144.300 -> 144.300
|
||||||
|
*/
|
||||||
|
private static String normalizeFrequencyString(
|
||||||
|
String rawInput
|
||||||
|
) {
|
||||||
|
int firstDotIndex = rawInput.indexOf('.');
|
||||||
|
|
||||||
|
if (firstDotIndex < 0) {
|
||||||
|
return rawInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
String decimalPart =
|
||||||
|
rawInput.substring(firstDotIndex + 1);
|
||||||
|
|
||||||
|
if (!decimalPart.contains(".")) {
|
||||||
|
return rawInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
decimalPart =
|
||||||
|
decimalPart.replace(".", "");
|
||||||
|
|
||||||
|
return rawInput.substring(0, firstDotIndex + 1)
|
||||||
|
+ decimalPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable result of one explicit-frequency detection.
|
||||||
|
*/
|
||||||
|
public static final class DetectedFrequency {
|
||||||
|
|
||||||
|
private final Band band;
|
||||||
|
private final double frequencyMHz;
|
||||||
|
private final String sourceText;
|
||||||
|
|
||||||
|
private DetectedFrequency(
|
||||||
|
Band band,
|
||||||
|
double frequencyMHz,
|
||||||
|
String sourceText
|
||||||
|
) {
|
||||||
|
this.band = band;
|
||||||
|
this.frequencyMHz = frequencyMHz;
|
||||||
|
this.sourceText = sourceText;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Band getBand() {
|
||||||
|
return band;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double getFrequencyMHz() {
|
||||||
|
return frequencyMHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSourceText() {
|
||||||
|
return sourceText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ public final class PropagationFrequencyResolver {
|
|||||||
/** Explains why a frequency was selected. */
|
/** Explains why a frequency was selected. */
|
||||||
public enum Source {
|
public enum Source {
|
||||||
CURRENT_QRG,
|
CURRENT_QRG,
|
||||||
|
STATION_NAME_QRG,
|
||||||
STATION_NAME,
|
STATION_NAME,
|
||||||
DUAL_CATEGORY_FALLBACK,
|
DUAL_CATEGORY_FALLBACK,
|
||||||
CHAT_CATEGORY
|
CHAT_CATEGORY
|
||||||
@@ -90,6 +91,20 @@ public final class PropagationFrequencyResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FrequencyCandidate stationNameQrg =
|
||||||
|
findUniqueStationNameQrg(
|
||||||
|
supportedVariants,
|
||||||
|
usableBands
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stationNameQrg != null) {
|
||||||
|
return new Resolution(
|
||||||
|
stationNameQrg.band,
|
||||||
|
stationNameQrg.frequencyMHz,
|
||||||
|
Source.STATION_NAME_QRG
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
EnumSet<Band> nameBands = EnumSet.noneOf(Band.class);
|
EnumSet<Band> nameBands = EnumSet.noneOf(Band.class);
|
||||||
for (ChatMember variant : supportedVariants) {
|
for (ChatMember variant : supportedVariants) {
|
||||||
nameBands.addAll(
|
nameBands.addAll(
|
||||||
@@ -172,6 +187,73 @@ public final class PropagationFrequencyResolver {
|
|||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves one exact station-name QRG only when the evidence is unambiguous.
|
||||||
|
*
|
||||||
|
* <p>The same QRG repeated in several category variants counts only once.
|
||||||
|
* If different explicit QRGs are advertised, no run frequency is guessed and
|
||||||
|
* the caller continues with normal band-name/category resolution.</p>
|
||||||
|
*/
|
||||||
|
private static FrequencyCandidate findUniqueStationNameQrg(
|
||||||
|
List<ChatMember> variants,
|
||||||
|
EnumSet<Band> usableBands
|
||||||
|
) {
|
||||||
|
java.util.LinkedHashMap<String, FrequencyCandidate> uniqueCandidates =
|
||||||
|
new java.util.LinkedHashMap<>();
|
||||||
|
|
||||||
|
for (ChatMember variant : variants) {
|
||||||
|
if (variant == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (FrequencyTextParser.DetectedFrequency detectedFrequency
|
||||||
|
: FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
variant.getName()
|
||||||
|
)) {
|
||||||
|
|
||||||
|
Band band =
|
||||||
|
detectedFrequency.getBand();
|
||||||
|
|
||||||
|
if (!usableBands.contains(band)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
double frequencyMHz =
|
||||||
|
detectedFrequency.getFrequencyMHz();
|
||||||
|
|
||||||
|
String key =
|
||||||
|
band.name()
|
||||||
|
+ "|"
|
||||||
|
+ Double.toString(frequencyMHz);
|
||||||
|
|
||||||
|
uniqueCandidates.putIfAbsent(
|
||||||
|
key,
|
||||||
|
new FrequencyCandidate(
|
||||||
|
band,
|
||||||
|
frequencyMHz,
|
||||||
|
Long.MIN_VALUE
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* More than one different exact QRG means that we cannot safely
|
||||||
|
* identify one run frequency.
|
||||||
|
*/
|
||||||
|
if (uniqueCandidates.size() > 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueCandidates.size() == 1
|
||||||
|
? uniqueCandidates.values()
|
||||||
|
.iterator()
|
||||||
|
.next()
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private static boolean isSupportedVariant(ChatMember member) {
|
private static boolean isSupportedVariant(ChatMember member) {
|
||||||
if (member == null || member.getChatCategory() == null) {
|
if (member == null || member.getChatCategory() == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -518,6 +518,7 @@ public class ChatMember {
|
|||||||
this.frequency = frequency;
|
this.frequency = frequency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public long getActivityTimeLastInEpoch() {
|
public long getActivityTimeLastInEpoch() {
|
||||||
return activityTimeLastInEpoch;
|
return activityTimeLastInEpoch;
|
||||||
}
|
}
|
||||||
@@ -595,6 +596,40 @@ public class ChatMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the compatibility frequency only when no frequency is known yet.
|
||||||
|
*
|
||||||
|
* <p>This is used for an explicit QRG contained in the ON4KST station name.
|
||||||
|
* A subsequently detected chat QRG may overwrite this initial value, but a
|
||||||
|
* station-name QRG never replaces an already known frequency.</p>
|
||||||
|
*
|
||||||
|
* @param frequencyMhz explicit station-name frequency
|
||||||
|
* @return true when the frequency was initialized
|
||||||
|
*/
|
||||||
|
public boolean initializeFrequencyIfEmpty(double frequencyMhz) {
|
||||||
|
if (!Double.isFinite(frequencyMhz)
|
||||||
|
|| frequencyMhz <= 0.0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frequency == null) {
|
||||||
|
frequency = new SimpleStringProperty();
|
||||||
|
}
|
||||||
|
|
||||||
|
String currentFrequency = frequency.get();
|
||||||
|
|
||||||
|
if (currentFrequency != null
|
||||||
|
&& !currentFrequency.isBlank()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
frequency.set(
|
||||||
|
Double.toString(frequencyMhz)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets all worked information of this object to false. Scope: GUI, Reset Button
|
* Sets all worked information of this object to false. Scope: GUI, Reset Button
|
||||||
* for worked info, called by appcontroller
|
* for worked info, called by appcontroller
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package kst4contest.test;
|
||||||
|
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
import kst4contest.model.Band;
|
||||||
|
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.assertTrue;
|
||||||
|
|
||||||
|
class FrequencyTextParserTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsExplicitStationNameFrequencies() {
|
||||||
|
List<FrequencyTextParser.DetectedFrequency> detected =
|
||||||
|
FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
"Phil 432.357"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(1, detected.size());
|
||||||
|
assertEquals(
|
||||||
|
Band.B_432,
|
||||||
|
detected.get(0).getBand()
|
||||||
|
);
|
||||||
|
assertEquals(
|
||||||
|
432.357,
|
||||||
|
detected.get(0).getFrequencyMHz(),
|
||||||
|
0.000_001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsCommaAndMicrowaveFrequencies() {
|
||||||
|
List<FrequencyTextParser.DetectedFrequency> detected =
|
||||||
|
FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
"QRV 1296,210 / 10368.100"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(2, detected.size());
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Band.B_1296,
|
||||||
|
detected.get(0).getBand()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Band.B_10G,
|
||||||
|
detected.get(1).getBand()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detectsFiftyAndSeventyMhzFrequencies() {
|
||||||
|
assertEquals(
|
||||||
|
Band.B_50,
|
||||||
|
FrequencyTextParser
|
||||||
|
.findExplicitFrequencies("50.150")
|
||||||
|
.get(0)
|
||||||
|
.getBand()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Band.B_70,
|
||||||
|
FrequencyTextParser
|
||||||
|
.findExplicitFrequencies("70.200")
|
||||||
|
.get(0)
|
||||||
|
.getBand()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ignoresRelativeAndAmbiguousValues() {
|
||||||
|
assertTrue(
|
||||||
|
FrequencyTextParser
|
||||||
|
.findExplicitFrequencies(
|
||||||
|
"Mike .180"
|
||||||
|
)
|
||||||
|
.isEmpty()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
FrequencyTextParser
|
||||||
|
.findExplicitFrequencies(
|
||||||
|
"Mike 180"
|
||||||
|
)
|
||||||
|
.isEmpty()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
FrequencyTextParser
|
||||||
|
.findExplicitFrequencies(
|
||||||
|
"David 1.2"
|
||||||
|
)
|
||||||
|
.isEmpty()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
FrequencyTextParser
|
||||||
|
.findExplicitFrequencies(
|
||||||
|
"RST 599"
|
||||||
|
)
|
||||||
|
.isEmpty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizesSubKhzNotation() {
|
||||||
|
FrequencyTextParser.DetectedFrequency detected =
|
||||||
|
FrequencyTextParser
|
||||||
|
.findExplicitFrequencies(
|
||||||
|
"144.300.03"
|
||||||
|
)
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
assertEquals(Band.B_144, detected.getBand());
|
||||||
|
assertEquals(
|
||||||
|
144.30003,
|
||||||
|
detected.getFrequencyMHz(),
|
||||||
|
0.000_001
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import java.util.LinkedHashMap;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import kst4contest.logic.FrequencyTextParser;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds immutable map snapshots from the currently visible chat members.
|
* Builds immutable map snapshots from the currently visible chat members.
|
||||||
@@ -204,6 +205,92 @@ public final class MapCallsignRawSnapshotBuilder {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A QRG explicitly contained in the current station name does not expire.
|
||||||
|
*
|
||||||
|
* Recent dynamic QRG evidence has priority. Therefore station-name QRGs fill
|
||||||
|
* only bands for which no recent dynamic frequency is available.
|
||||||
|
*
|
||||||
|
* Different explicit QRGs for the same band are considered ambiguous and are
|
||||||
|
* not reduced to one arbitrary run frequency.
|
||||||
|
*/
|
||||||
|
Map<Band, Map<String, FrequencyTextParser.DetectedFrequency>>
|
||||||
|
stationNameFrequenciesByBand =
|
||||||
|
new EnumMap<>(Band.class);
|
||||||
|
|
||||||
|
for (ChatMember variant : variants) {
|
||||||
|
if (variant == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (FrequencyTextParser.DetectedFrequency detectedFrequency
|
||||||
|
: FrequencyTextParser.findExplicitFrequencies(
|
||||||
|
variant.getName()
|
||||||
|
)) {
|
||||||
|
|
||||||
|
Band band =
|
||||||
|
detectedFrequency.getBand();
|
||||||
|
|
||||||
|
if (availableBands == null
|
||||||
|
|| !availableBands.contains(band)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
stationNameFrequenciesByBand
|
||||||
|
.computeIfAbsent(
|
||||||
|
band,
|
||||||
|
ignored -> new LinkedHashMap<>()
|
||||||
|
)
|
||||||
|
.putIfAbsent(
|
||||||
|
Double.toString(
|
||||||
|
detectedFrequency.getFrequencyMHz()
|
||||||
|
),
|
||||||
|
detectedFrequency
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Map.Entry<
|
||||||
|
Band,
|
||||||
|
Map<String, FrequencyTextParser.DetectedFrequency>>
|
||||||
|
entry : stationNameFrequenciesByBand.entrySet()) {
|
||||||
|
|
||||||
|
Band band = entry.getKey();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* A recent QRG detected from chat always wins.
|
||||||
|
*/
|
||||||
|
if (latestByBand.containsKey(band)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do not guess when several different QRGs were published for the
|
||||||
|
* same band.
|
||||||
|
*/
|
||||||
|
if (entry.getValue().size() != 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
FrequencyTextParser.DetectedFrequency detectedFrequency =
|
||||||
|
entry.getValue()
|
||||||
|
.values()
|
||||||
|
.iterator()
|
||||||
|
.next();
|
||||||
|
|
||||||
|
latestByBand.put(
|
||||||
|
band,
|
||||||
|
new FrequencyCandidate(
|
||||||
|
band,
|
||||||
|
formatFrequency(
|
||||||
|
detectedFrequency.getFrequencyMHz()
|
||||||
|
),
|
||||||
|
Long.MIN_VALUE
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
|
LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ public final class StationMapBridge {
|
|||||||
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
|
stationMapView.setOnCallsignRawSelected(this::handleMapCallsignSelection);
|
||||||
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
|
stationMapView.setOnTriggerClusterSpot(this::handleExplicitClusterSpot);
|
||||||
|
|
||||||
|
stationMapView.setOnResetView(this::handleMapReset);
|
||||||
|
|
||||||
chatController.getLst_chatMemberSortedFilteredList().addListener(
|
chatController.getLst_chatMemberSortedFilteredList().addListener(
|
||||||
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
|
(ListChangeListener<ChatMember>) change -> scheduleRefresh()
|
||||||
);
|
);
|
||||||
@@ -98,6 +100,35 @@ public final class StationMapBridge {
|
|||||||
requestImmediateRefresh();
|
requestImmediateRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleMapReset() {
|
||||||
|
Runnable resetAction = () -> {
|
||||||
|
/*
|
||||||
|
* Ignore an analysis result that may still arrive for the
|
||||||
|
* previously selected station.
|
||||||
|
*/
|
||||||
|
pathAnalysisGeneration.incrementAndGet();
|
||||||
|
lastPathAnalysisRequestSignature = "";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Clear the application's central station selection.
|
||||||
|
*/
|
||||||
|
chatController.getScoreService().setSelectedChatMember(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Keep the main station table synchronized with the central selection.
|
||||||
|
*/
|
||||||
|
chatMemberTable.getSelectionModel().clearSelection();
|
||||||
|
|
||||||
|
requestImmediateRefresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Platform.isFxApplicationThread()) {
|
||||||
|
resetAction.run();
|
||||||
|
} else {
|
||||||
|
Platform.runLater(resetAction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void showWindow() {
|
public void showWindow() {
|
||||||
stationMapView.showWindow();
|
stationMapView.showWindow();
|
||||||
requestImmediateRefresh();
|
requestImmediateRefresh();
|
||||||
|
|||||||
@@ -87,14 +87,15 @@ public final class StationMapView {
|
|||||||
private final Button pathAnalysisVisibilityButton = new Button();
|
private final Button pathAnalysisVisibilityButton = new Button();
|
||||||
private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip();
|
private final Tooltip pathAnalysisVisibilityTooltip = new Tooltip();
|
||||||
|
|
||||||
|
private final Button resetViewButton = new Button("Reset view");
|
||||||
|
private final Tooltip statusTooltip = new Tooltip();
|
||||||
|
|
||||||
|
private Runnable onResetView;
|
||||||
|
|
||||||
|
private double lastDetailDividerPosition = 0.65;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
private final Label detailCallsignValue = new Label("-");
|
|
||||||
private final Label detailLocatorValue = new Label("-");
|
|
||||||
private final Label detailQrbValue = new Label("-");
|
|
||||||
private final Label detailQtfValue = new Label("-");
|
|
||||||
private final Label detailBandsValue = new Label("-");
|
|
||||||
private final TextArea detailFrequenciesArea = new TextArea();
|
|
||||||
private final Label detailAirplanesValue = new Label("-");
|
|
||||||
private final Button triggerClusterSpotButton = new Button("Trigger cluster spot");
|
private final Button triggerClusterSpotButton = new Button("Trigger cluster spot");
|
||||||
|
|
||||||
private final Label detailPathFromLocatorValue = new Label("-");
|
private final Label detailPathFromLocatorValue = new Label("-");
|
||||||
@@ -210,6 +211,11 @@ public final class StationMapView {
|
|||||||
this.onTriggerClusterSpot = onTriggerClusterSpot;
|
this.onTriggerClusterSpot = onTriggerClusterSpot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setOnResetView(Runnable onResetView) {
|
||||||
|
this.onResetView = onResetView;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public void showWindow() {
|
public void showWindow() {
|
||||||
|
|
||||||
applyThemeFromPreferences();
|
applyThemeFromPreferences();
|
||||||
@@ -282,10 +288,6 @@ public final class StationMapView {
|
|||||||
|
|
||||||
stage.setTitle("Station Map");
|
stage.setTitle("Station Map");
|
||||||
|
|
||||||
detailFrequenciesArea.setEditable(false);
|
|
||||||
detailFrequenciesArea.setWrapText(true);
|
|
||||||
detailFrequenciesArea.setPrefRowCount(4);
|
|
||||||
|
|
||||||
detailPathEndpointsValue.setWrapText(true);
|
detailPathEndpointsValue.setWrapText(true);
|
||||||
detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE);
|
detailPathEndpointsValue.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
|
||||||
@@ -299,12 +301,23 @@ public final class StationMapView {
|
|||||||
detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE);
|
detailPathMechanismsValue.setMaxWidth(Double.MAX_VALUE);
|
||||||
|
|
||||||
triggerClusterSpotButton.setDisable(true);
|
triggerClusterSpotButton.setDisable(true);
|
||||||
|
triggerClusterSpotButton.setVisible(false);
|
||||||
|
triggerClusterSpotButton.setManaged(false);
|
||||||
|
triggerClusterSpotButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
|
||||||
triggerClusterSpotButton.setOnAction(event -> {
|
triggerClusterSpotButton.setOnAction(event -> {
|
||||||
if (detailCallsignRaw != null && onTriggerClusterSpot != null) {
|
if (detailCallsignRaw != null && onTriggerClusterSpot != null) {
|
||||||
onTriggerClusterSpot.accept(detailCallsignRaw);
|
onTriggerClusterSpot.accept(detailCallsignRaw);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resetViewButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
|
resetViewButton.setOnAction(event -> {
|
||||||
|
if (onResetView != null) {
|
||||||
|
onResetView.run();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
|
pathAnalysisVisibilityButton.setMinWidth(Region.USE_PREF_SIZE);
|
||||||
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
|
pathAnalysisVisibilityButton.setTooltip(pathAnalysisVisibilityTooltip);
|
||||||
pathAnalysisVisibilityButton.setOnAction(event ->
|
pathAnalysisVisibilityButton.setOnAction(event ->
|
||||||
@@ -377,7 +390,7 @@ public final class StationMapView {
|
|||||||
);
|
);
|
||||||
|
|
||||||
pathAnalysisSection = createPathAnalysisSection();
|
pathAnalysisSection = createPathAnalysisSection();
|
||||||
detailPane = new VBox(10, createSelectedStationSection(), pathAnalysisSection);
|
detailPane = new VBox(10, pathAnalysisSection);
|
||||||
|
|
||||||
detailPane.setPadding(new Insets(10));
|
detailPane.setPadding(new Insets(10));
|
||||||
|
|
||||||
@@ -489,20 +502,25 @@ public final class StationMapView {
|
|||||||
* must always have an obvious way to restore a previously hidden analysis.
|
* must always have an obvious way to restore a previously hidden analysis.
|
||||||
*/
|
*/
|
||||||
private HBox createMapHeader() {
|
private HBox createMapHeader() {
|
||||||
// The status may be shortened before the show/hide control is ever clipped.
|
|
||||||
statusLabel.setMinWidth(0);
|
statusLabel.setMinWidth(0);
|
||||||
statusLabel.setMaxWidth(Double.MAX_VALUE);
|
statusLabel.setMaxWidth(Double.MAX_VALUE);
|
||||||
statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS);
|
statusLabel.setTextOverrun(OverrunStyle.ELLIPSIS);
|
||||||
|
statusLabel.setTooltip(statusTooltip);
|
||||||
|
|
||||||
HBox header = new HBox(
|
HBox header = new HBox(
|
||||||
10,
|
10,
|
||||||
statusLabel,
|
statusLabel,
|
||||||
|
triggerClusterSpotButton,
|
||||||
|
resetViewButton,
|
||||||
pathAnalysisHiddenHintLabel,
|
pathAnalysisHiddenHintLabel,
|
||||||
pathAnalysisVisibilityButton
|
pathAnalysisVisibilityButton
|
||||||
);
|
);
|
||||||
|
|
||||||
header.setAlignment(Pos.CENTER_LEFT);
|
header.setAlignment(Pos.CENTER_LEFT);
|
||||||
header.setPadding(new Insets(8));
|
header.setPadding(new Insets(8));
|
||||||
|
|
||||||
HBox.setHgrow(statusLabel, Priority.ALWAYS);
|
HBox.setHgrow(statusLabel, Priority.ALWAYS);
|
||||||
|
|
||||||
return header;
|
return header;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +541,7 @@ public final class StationMapView {
|
|||||||
|
|
||||||
pathAnalysisSection.setVisible(visible);
|
pathAnalysisSection.setVisible(visible);
|
||||||
pathAnalysisSection.setManaged(visible);
|
pathAnalysisSection.setManaged(visible);
|
||||||
|
updateDetailPanePresence(visible);
|
||||||
|
|
||||||
pathAnalysisHiddenHintLabel.setVisible(!visible);
|
pathAnalysisHiddenHintLabel.setVisible(!visible);
|
||||||
pathAnalysisHiddenHintLabel.setManaged(!visible);
|
pathAnalysisHiddenHintLabel.setManaged(!visible);
|
||||||
@@ -567,45 +586,6 @@ public final class StationMapView {
|
|||||||
: MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS;
|
: MINIMUM_HEIGHT_WITHOUT_PATH_ANALYSIS;
|
||||||
}
|
}
|
||||||
|
|
||||||
private VBox createSelectedStationSection() {
|
|
||||||
GridPane detailGrid = new GridPane();
|
|
||||||
detailGrid.setHgap(8);
|
|
||||||
detailGrid.setVgap(6);
|
|
||||||
|
|
||||||
configureCompactGrid(detailGrid);
|
|
||||||
|
|
||||||
int row = 0;
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Station:"), 0, row);
|
|
||||||
detailGrid.add(detailCallsignValue, 1, row++);
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Locator:"), 0, row);
|
|
||||||
detailGrid.add(detailLocatorValue, 1, row++);
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Path:"), 0, row);
|
|
||||||
Label compactPathValue = new Label();
|
|
||||||
compactPathValue.textProperty().bind(
|
|
||||||
detailQrbValue.textProperty()
|
|
||||||
.concat(" / ")
|
|
||||||
.concat(detailQtfValue.textProperty())
|
|
||||||
);
|
|
||||||
detailGrid.add(compactPathValue, 1, row++);
|
|
||||||
|
|
||||||
detailGrid.add(new Label("Bands:"), 0, row);
|
|
||||||
detailGrid.add(detailBandsValue, 1, row++);
|
|
||||||
|
|
||||||
// Frequencies are useful, but they consume vertical space. Keep them compact.
|
|
||||||
detailFrequenciesArea.setPrefRowCount(2);
|
|
||||||
detailGrid.add(new Label("QRG:"), 0, row);
|
|
||||||
detailGrid.add(detailFrequenciesArea, 1, row++);
|
|
||||||
|
|
||||||
return new VBox(8,
|
|
||||||
new Label("Selected station"),
|
|
||||||
new Separator(Orientation.HORIZONTAL),
|
|
||||||
detailGrid,
|
|
||||||
triggerClusterSpotButton
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ensures that the labels remains visible
|
* ensures that the labels remains visible
|
||||||
@@ -950,58 +930,107 @@ public final class StationMapView {
|
|||||||
|
|
||||||
private void updateStatusLabel() {
|
private void updateStatusLabel() {
|
||||||
StringBuilder text = new StringBuilder();
|
StringBuilder text = new StringBuilder();
|
||||||
text.append("Showing ").append(lastSnapshots.size()).append(" visible stations");
|
|
||||||
|
text.append("Showing ")
|
||||||
|
.append(lastSnapshots.size())
|
||||||
|
.append(" visible stations");
|
||||||
|
|
||||||
if (filteredViewActive) {
|
if (filteredViewActive) {
|
||||||
text.append(" | filtered view active");
|
text.append(" | filtered view active");
|
||||||
}
|
}
|
||||||
|
|
||||||
statusLabel.setText(text.toString());
|
MapCallsignRawSnapshot selectedSnapshot = lastSelectedSnapshot;
|
||||||
|
|
||||||
|
if (selectedSnapshot != null) {
|
||||||
|
text.append(" | Selected: ")
|
||||||
|
.append(selectedSnapshot.displayCallSign());
|
||||||
|
|
||||||
|
if (!selectedSnapshot.locator6().isBlank()) {
|
||||||
|
text.append(" | ")
|
||||||
|
.append(selectedSnapshot.locator6());
|
||||||
|
}
|
||||||
|
|
||||||
|
text.append(" | ")
|
||||||
|
.append(String.format(
|
||||||
|
Locale.US,
|
||||||
|
"%.0f km / %.0f°",
|
||||||
|
selectedSnapshot.qrbKm(),
|
||||||
|
selectedSnapshot.qtfDeg()
|
||||||
|
));
|
||||||
|
|
||||||
|
String bandText = selectedSnapshot.bandSummary().isBlank()
|
||||||
|
? "-"
|
||||||
|
: selectedSnapshot.bandSummary();
|
||||||
|
|
||||||
|
if (selectedSnapshot.offersSelectedBand()) {
|
||||||
|
bandText += " B+";
|
||||||
|
}
|
||||||
|
|
||||||
|
text.append(" | Bands: ")
|
||||||
|
.append(bandText);
|
||||||
|
|
||||||
|
String frequencies = selectedSnapshot.detailFrequencyText();
|
||||||
|
|
||||||
|
if (frequencies != null && !frequencies.isBlank()) {
|
||||||
|
frequencies = frequencies
|
||||||
|
.replace('\n', ' ')
|
||||||
|
.replace('\r', ' ')
|
||||||
|
.replaceAll("\\s+", " ")
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
text.append(" | QRG: ")
|
||||||
|
.append(frequencies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String statusText = text.toString();
|
||||||
|
|
||||||
|
statusLabel.setText(statusText);
|
||||||
|
statusTooltip.setText(statusText);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) {
|
private void updateDetailPanel(MapCallsignRawSnapshot selectedSnapshot) {
|
||||||
if (selectedSnapshot == null) {
|
if (selectedSnapshot == null) {
|
||||||
clearSelectedStationPanel();
|
detailCallsignRaw = null;
|
||||||
|
|
||||||
|
triggerClusterSpotButton.setDisable(true);
|
||||||
|
triggerClusterSpotButton.setVisible(false);
|
||||||
|
triggerClusterSpotButton.setManaged(false);
|
||||||
|
|
||||||
clearPathAnalysisPanel();
|
clearPathAnalysisPanel();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedStationPanel(selectedSnapshot);
|
detailCallsignRaw = selectedSnapshot.callSignRaw();
|
||||||
|
|
||||||
|
triggerClusterSpotButton.setDisable(false);
|
||||||
|
triggerClusterSpotButton.setVisible(true);
|
||||||
|
triggerClusterSpotButton.setManaged(true);
|
||||||
|
|
||||||
if (selectedSnapshot == null) {
|
|
||||||
updatePathAnalysisPanel(PathAnalysisResult.waitingForSelection(homeLocator6));
|
|
||||||
} else {
|
|
||||||
updatePathAnalysisPanel(lastPathAnalysisResult);
|
updatePathAnalysisPanel(lastPathAnalysisResult);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void updateDetailPanePresence(boolean visible) {
|
||||||
|
if (mainSplitPane == null || detailScrollPane == null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearSelectedStationPanel() {
|
if (visible) {
|
||||||
detailCallsignRaw = null;
|
if (!mainSplitPane.getItems().contains(detailScrollPane)) {
|
||||||
detailCallsignValue.setText("-");
|
mainSplitPane.getItems().add(detailScrollPane);
|
||||||
detailLocatorValue.setText("-");
|
|
||||||
detailQrbValue.setText("-");
|
Platform.runLater(() ->
|
||||||
detailQtfValue.setText("-");
|
mainSplitPane.setDividerPositions(lastDetailDividerPosition)
|
||||||
detailBandsValue.setText("-");
|
);
|
||||||
detailFrequenciesArea.setText("-");
|
}
|
||||||
detailAirplanesValue.setText("-");
|
} else {
|
||||||
triggerClusterSpotButton.setDisable(true);
|
if (!mainSplitPane.getDividers().isEmpty()) {
|
||||||
|
lastDetailDividerPosition =
|
||||||
|
mainSplitPane.getDividers().get(0).getPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void updateSelectedStationPanel(MapCallsignRawSnapshot selectedSnapshot) {
|
mainSplitPane.getItems().remove(detailScrollPane);
|
||||||
detailCallsignRaw = selectedSnapshot.callSignRaw();
|
|
||||||
detailCallsignValue.setText(selectedSnapshot.displayCallSign());
|
|
||||||
detailLocatorValue.setText(selectedSnapshot.locator6());
|
|
||||||
detailQrbValue.setText(String.format(Locale.US, "%.0f km", selectedSnapshot.qrbKm()));
|
|
||||||
detailQtfValue.setText(String.format(Locale.US, "%.0f°", selectedSnapshot.qtfDeg()));
|
|
||||||
String bandText = selectedSnapshot.bandSummary().isBlank() ? "-" : selectedSnapshot.bandSummary();
|
|
||||||
if (selectedSnapshot.offersSelectedBand()) {
|
|
||||||
bandText += " B+";
|
|
||||||
}
|
}
|
||||||
detailBandsValue.setText(bandText);
|
|
||||||
detailFrequenciesArea.setText(selectedSnapshot.detailFrequencyText());
|
|
||||||
detailAirplanesValue.setText(String.valueOf(selectedSnapshot.reachableAirplanes()));
|
|
||||||
triggerClusterSpotButton.setDisable(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void clearPathAnalysisPanel() {
|
private void clearPathAnalysisPanel() {
|
||||||
@@ -1331,13 +1360,13 @@ public final class StationMapView {
|
|||||||
mainSplitPane.setStyle("-fx-background-color: #2b3035;");
|
mainSplitPane.setStyle("-fx-background-color: #2b3035;");
|
||||||
detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;");
|
detailPane.setStyle("-fx-background-color: #31373c; -fx-border-color: #4c565c; -fx-border-width: 0 0 0 1;");
|
||||||
statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
statusLabel.setStyle("-fx-background-color: #373e43; -fx-text-fill: lightgray; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
||||||
detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
|
// detailFrequenciesArea.setStyle("-fx-control-inner-background: #444b50; -fx-text-fill: lightgray;");
|
||||||
} else {
|
} else {
|
||||||
rootPane.setStyle("-fx-background-color: #f2f2f2;");
|
rootPane.setStyle("-fx-background-color: #f2f2f2;");
|
||||||
mainSplitPane.setStyle("-fx-background-color: #f2f2f2;");
|
mainSplitPane.setStyle("-fx-background-color: #f2f2f2;");
|
||||||
detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;");
|
detailPane.setStyle("-fx-background-color: #f7f7f7; -fx-border-color: #d0d0d0; -fx-border-width: 0 0 0 1;");
|
||||||
statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
statusLabel.setStyle("-fx-background-color: #f7f7f7; -fx-text-fill: #333333; -fx-padding: 8 10 8 10; -fx-background-radius: 4;");
|
||||||
detailFrequenciesArea.setStyle("");
|
// detailFrequenciesArea.setStyle("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,39 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
class BandOpportunityResolverTest {
|
class BandOpportunityResolverTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void explicitStationNameFrequencyProvidesBandEvidence() {
|
||||||
|
ChatMember station = new ChatMember();
|
||||||
|
station.setName("Phil 432.357");
|
||||||
|
|
||||||
|
BandOpportunityResolver.Resolution resolution =
|
||||||
|
BandOpportunityResolver.resolve(
|
||||||
|
List.of(station),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
EnumSet.of(Band.B_432),
|
||||||
|
resolution.getOfferedBands()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void relativeStationNameFrequencyDoesNotProvideBandEvidence() {
|
||||||
|
ChatMember station = new ChatMember();
|
||||||
|
station.setName("Mike .180");
|
||||||
|
|
||||||
|
BandOpportunityResolver.Resolution resolution =
|
||||||
|
BandOpportunityResolver.resolve(
|
||||||
|
List.of(station),
|
||||||
|
System.currentTimeMillis()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
resolution.getOfferedBands().isEmpty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void resolvesCommonShorthandBandsFromStationName() {
|
void resolvesCommonShorthandBandsFromStationName() {
|
||||||
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
|
EnumSet<Band> bands = BandOpportunityResolver.detectBandsFromStationName(
|
||||||
|
|||||||
@@ -15,6 +15,70 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
|||||||
|
|
||||||
class MapCallsignRawSnapshotBuilderTest {
|
class MapCallsignRawSnapshotBuilderTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void explicitStationNameQrgIsShownInMapSnapshot() {
|
||||||
|
ChatMember station =
|
||||||
|
buildStation(
|
||||||
|
"G0JSB",
|
||||||
|
"Phil 432.357",
|
||||||
|
"IO91AA",
|
||||||
|
1_000L
|
||||||
|
);
|
||||||
|
|
||||||
|
MapCallsignRawSnapshot snapshot =
|
||||||
|
new MapCallsignRawSnapshotBuilder()
|
||||||
|
.buildSnapshots(
|
||||||
|
List.of(station),
|
||||||
|
null,
|
||||||
|
EnumSet.of(Band.B_432)
|
||||||
|
)
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"432",
|
||||||
|
snapshot.bandSummary()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"432.357",
|
||||||
|
snapshot
|
||||||
|
.lastKnownFrequenciesByBand()
|
||||||
|
.get("432")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recentDynamicQrgWinsOverStationNameQrgInMap() {
|
||||||
|
ChatMember station =
|
||||||
|
buildStation(
|
||||||
|
"G0JSB",
|
||||||
|
"Phil 432.357",
|
||||||
|
"IO91AA",
|
||||||
|
1_000L
|
||||||
|
);
|
||||||
|
|
||||||
|
station.addKnownFrequency(
|
||||||
|
Band.B_432,
|
||||||
|
432.335
|
||||||
|
);
|
||||||
|
|
||||||
|
MapCallsignRawSnapshot snapshot =
|
||||||
|
new MapCallsignRawSnapshotBuilder()
|
||||||
|
.buildSnapshots(
|
||||||
|
List.of(station),
|
||||||
|
null,
|
||||||
|
EnumSet.of(Band.B_432)
|
||||||
|
)
|
||||||
|
.get(0);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"432.335",
|
||||||
|
snapshot
|
||||||
|
.lastKnownFrequenciesByBand()
|
||||||
|
.get("432")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void marksSnapshotWhenNameAdvertisesSelectedBand() {
|
void marksSnapshotWhenNameAdvertisesSelectedBand() {
|
||||||
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
|
ChatMember station = buildStation("DL1ABC", "QRV 2-70-23", "JN58TD", 1_000L);
|
||||||
|
|||||||
@@ -16,6 +16,113 @@ class PropagationFrequencyResolverTest {
|
|||||||
|
|
||||||
private static final long NOW = 10_000_000L;
|
private static final long NOW = 10_000_000L;
|
||||||
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exactStationNameQrgWinsOverBandAndCategoryFallback() {
|
||||||
|
ChatMember station =
|
||||||
|
station(
|
||||||
|
ChatCategory.VUHF,
|
||||||
|
"Phil 432.357"
|
||||||
|
);
|
||||||
|
|
||||||
|
PropagationFrequencyResolver.Resolution resolution =
|
||||||
|
resolve(
|
||||||
|
List.of(station),
|
||||||
|
EnumSet.of(
|
||||||
|
Band.B_144,
|
||||||
|
Band.B_432
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
Band.B_432,
|
||||||
|
resolution.getBand()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
432.357,
|
||||||
|
resolution.getAnalysisFrequencyMHz(),
|
||||||
|
0.000_001
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
PropagationFrequencyResolver.Source.STATION_NAME_QRG,
|
||||||
|
resolution.getSource()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void recentChatQrgWinsOverExactStationNameQrg() {
|
||||||
|
ChatMember station =
|
||||||
|
station(
|
||||||
|
ChatCategory.VUHF,
|
||||||
|
"Phil 432.357"
|
||||||
|
);
|
||||||
|
|
||||||
|
addCurrentQrg(
|
||||||
|
station,
|
||||||
|
Band.B_432,
|
||||||
|
432.335,
|
||||||
|
NOW - 1_000L
|
||||||
|
);
|
||||||
|
|
||||||
|
PropagationFrequencyResolver.Resolution resolution =
|
||||||
|
resolve(
|
||||||
|
List.of(station),
|
||||||
|
EnumSet.of(Band.B_432)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
432.335,
|
||||||
|
resolution.getAnalysisFrequencyMHz(),
|
||||||
|
0.000_001
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
PropagationFrequencyResolver.Source.CURRENT_QRG,
|
||||||
|
resolution.getSource()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void multipleStationNameQrgsAreNotTreatedAsOneRunFrequency() {
|
||||||
|
ChatMember station =
|
||||||
|
station(
|
||||||
|
ChatCategory.EMEJT65,
|
||||||
|
"QRV 432.357 1296.210"
|
||||||
|
);
|
||||||
|
|
||||||
|
PropagationFrequencyResolver.Resolution resolution =
|
||||||
|
resolve(
|
||||||
|
List.of(station),
|
||||||
|
EnumSet.of(
|
||||||
|
Band.B_432,
|
||||||
|
Band.B_1296
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Both bands are known, but no exact run QRG is guessed.
|
||||||
|
* Existing station-name band selection therefore chooses the
|
||||||
|
* lowest usable advertised band.
|
||||||
|
*/
|
||||||
|
assertEquals(
|
||||||
|
Band.B_432,
|
||||||
|
resolution.getBand()
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
432.0,
|
||||||
|
resolution.getAnalysisFrequencyMHz(),
|
||||||
|
0.000_001
|
||||||
|
);
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
PropagationFrequencyResolver.Source.STATION_NAME,
|
||||||
|
resolution.getSource()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void currentQrgWinsOverNameAndCategoryFallback() {
|
void currentQrgWinsOverNameAndCategoryFallback() {
|
||||||
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
|
ChatMember station = station(ChatCategory.MICROWAVE, "QRV 3cm");
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB |
@@ -2383,3 +2383,7 @@ OZ6TY;Henning;JO55XE;StringProperty [value: 144.196]; wkd true; wkd144 true; wkd
|
|||||||
DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
DF7KF;Dithmar;JO30FK;StringProperty [value: null]; wkd true; wkd144 true; wkd432false; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
DH1NFJ;Jochen;JO50QL;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
DH1NFJ;Jochen;JO50QL;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 3: Microwave
|
||||||
PA2RU;René;JO32LT;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
PA2RU;René;JO32LT;StringProperty [value: null]; wkd true; wkd144 false; wkd432false; wkd1240true; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
SM6VTZ;Chris .135;JO58UJ;StringProperty [value: 144.135]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
SM7EYW;Torleif 432,205;JO65NK;StringProperty [value: 144.205]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
DJ8MS;Tor_70cm;JO54UC;StringProperty [value: null]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
|
DK0MM;Jens/Alex;JN49IU;StringProperty [value: 432.305]; wkd true; wkd144 false; wkd432true; wkd1240false; wkd2300false; wkd3400false; wkd5600false; wkd10Gfalse ; 2: 144/432 MHz
|
||||||
@@ -71,6 +71,14 @@ function getManualPageOrder(lang, slug) {
|
|||||||
return index >= 0 ? index : 999;
|
return index >= 0 ? index : 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function githubCompatibleSlug(value) {
|
||||||
|
return (value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
|
||||||
|
.replace(/\s+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
function rewriteManualLinks(content, lang) {
|
function rewriteManualLinks(content, lang) {
|
||||||
return (content || "")
|
return (content || "")
|
||||||
|
|
||||||
@@ -157,6 +165,7 @@ module.exports = function (eleventyConfig) {
|
|||||||
linkify: true,
|
linkify: true,
|
||||||
typographer: true
|
typographer: true
|
||||||
}).use(markdownItAnchor, {
|
}).use(markdownItAnchor, {
|
||||||
|
slugify: githubCompatibleSlug,
|
||||||
permalink: markdownItAnchor.permalink.headerLink()
|
permalink: markdownItAnchor.permalink.headerLink()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "eleventy --serve",
|
"start": "eleventy --serve",
|
||||||
"build": "eleventy"
|
"build": "eleventy && npm run validate:version-info",
|
||||||
|
"validate:version-info": "node scripts/validate-version-info.js",
|
||||||
|
"test": "node --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@11ty/eleventy": "^3.0.0"
|
"@11ty/eleventy": "^3.0.0"
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const DEFAULT_FILE = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"_site",
|
||||||
|
"kst4ContestVersionInfo.xml"
|
||||||
|
);
|
||||||
|
|
||||||
|
function fail(message) {
|
||||||
|
throw new Error(`[versionInfo validation] ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseVersion(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/^v/, "")
|
||||||
|
.split("-")[0]
|
||||||
|
.split("+")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractTag(xml, tagName) {
|
||||||
|
const match = xml.match(
|
||||||
|
new RegExp(`<${tagName}>([\\s\\S]*?)<\\/${tagName}>`)
|
||||||
|
);
|
||||||
|
|
||||||
|
return match ? match[1].trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertWellFormedStructure(xml) {
|
||||||
|
if (/&(?!amp;|lt;|gt;|quot;|apos;|#\d+;|#x[0-9a-f]+;)/i.test(xml)) {
|
||||||
|
fail("the XML contains an unescaped ampersand");
|
||||||
|
}
|
||||||
|
|
||||||
|
const withoutCommentsAndDeclaration = xml
|
||||||
|
.replace(/<!--[\s\S]*?-->/g, "")
|
||||||
|
.replace(/<\?[\s\S]*?\?>/g, "");
|
||||||
|
|
||||||
|
const tagPattern =
|
||||||
|
/<\/?([A-Za-z_][\w:.-]*)(?:\s[^<>]*?)?\s*\/?>/g;
|
||||||
|
|
||||||
|
const stack = [];
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while (
|
||||||
|
(match = tagPattern.exec(withoutCommentsAndDeclaration)) !== null
|
||||||
|
) {
|
||||||
|
const fullTag = match[0];
|
||||||
|
const tagName = match[1];
|
||||||
|
|
||||||
|
if (fullTag.startsWith("</")) {
|
||||||
|
const openTag = stack.pop();
|
||||||
|
|
||||||
|
if (openTag !== tagName) {
|
||||||
|
fail(
|
||||||
|
`closing tag </${tagName}> does not match `
|
||||||
|
+ `<${openTag || "none"}>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (!fullTag.endsWith("/>")) {
|
||||||
|
stack.push(tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stack.length > 0) {
|
||||||
|
fail(`unclosed tag <${stack[stack.length - 1]}>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remainingMarkup =
|
||||||
|
withoutCommentsAndDeclaration.replace(tagPattern, "");
|
||||||
|
|
||||||
|
// A plain ">" is legal character data and occurs in historical
|
||||||
|
// changelog notation such as "->". A remaining "<" cannot be legal
|
||||||
|
// after all valid tags have been removed.
|
||||||
|
if (/</.test(remainingMarkup)) {
|
||||||
|
fail("the XML contains malformed markup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertContainsTag(block, tagName) {
|
||||||
|
const pattern =
|
||||||
|
new RegExp(`<${tagName}>[\\s\\S]*?<\\/${tagName}>`);
|
||||||
|
|
||||||
|
if (!pattern.test(block)) {
|
||||||
|
fail(`required element <${tagName}> is missing`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateVersionInfo(xml, expectedStableVersion = "") {
|
||||||
|
if (!xml || Buffer.byteLength(xml, "utf8") < 500) {
|
||||||
|
fail("the generated file is empty or implausibly small");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertWellFormedStructure(xml);
|
||||||
|
|
||||||
|
const completeDocumentPattern =
|
||||||
|
/^<\?xml[^>]*>\s*<praktiKST>[\s\S]*<\/praktiKST>\s*$/;
|
||||||
|
|
||||||
|
if (!completeDocumentPattern.test(xml)) {
|
||||||
|
fail(
|
||||||
|
"the document does not contain one complete "
|
||||||
|
+ "<praktiKST> root element"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestVersionBlock = extractTag(xml, "latestVersion");
|
||||||
|
|
||||||
|
if (latestVersionBlock === null) {
|
||||||
|
fail("<latestVersion> is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tagName of [
|
||||||
|
"versionNumber",
|
||||||
|
"semanticVersion",
|
||||||
|
"adminMessage",
|
||||||
|
"majorChanges",
|
||||||
|
"latestVersionPathOnWebserver"
|
||||||
|
]) {
|
||||||
|
assertContainsTag(latestVersionBlock, tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacyVersion =
|
||||||
|
extractTag(latestVersionBlock, "versionNumber");
|
||||||
|
|
||||||
|
const semanticVersion =
|
||||||
|
extractTag(latestVersionBlock, "semanticVersion");
|
||||||
|
|
||||||
|
const releaseUrl =
|
||||||
|
extractTag(
|
||||||
|
latestVersionBlock,
|
||||||
|
"latestVersionPathOnWebserver"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!legacyVersion
|
||||||
|
|| !/^\d+(?:\.\d+)?$/.test(legacyVersion)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
"<versionNumber> is not a valid legacy numeric version"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!semanticVersion
|
||||||
|
|| !/^\d+\.\d+(?:\.\d+)?$/.test(semanticVersion)
|
||||||
|
) {
|
||||||
|
fail("<semanticVersion> is not a valid Stable version");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!releaseUrl
|
||||||
|
|| !releaseUrl.startsWith(
|
||||||
|
"https://github.com/praktimarc/"
|
||||||
|
+ "kst4contest/releases/tag/"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
"<latestVersionPathOnWebserver> is not "
|
||||||
|
+ "a KST4Contest release URL"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeLogs = [
|
||||||
|
...xml.matchAll(
|
||||||
|
/<changeLog>([\s\S]*?)<\/changeLog>/g
|
||||||
|
)
|
||||||
|
].map((match) => match[1]);
|
||||||
|
|
||||||
|
if (changeLogs.length === 0) {
|
||||||
|
fail(
|
||||||
|
"the document does not contain any "
|
||||||
|
+ "<changeLog> entries"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of changeLogs) {
|
||||||
|
for (const tagName of [
|
||||||
|
"changedVersionNumber",
|
||||||
|
"date",
|
||||||
|
"description",
|
||||||
|
"added",
|
||||||
|
"changed",
|
||||||
|
"fixed",
|
||||||
|
"removed"
|
||||||
|
]) {
|
||||||
|
assertContainsTag(entry, tagName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = normaliseVersion(expectedStableVersion);
|
||||||
|
|
||||||
|
if (expected) {
|
||||||
|
if (semanticVersion !== expected) {
|
||||||
|
fail(
|
||||||
|
`latest Stable version ${semanticVersion} `
|
||||||
|
+ `does not match expected release ${expected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const releaseEntryExists = changeLogs.some(
|
||||||
|
(entry) =>
|
||||||
|
extractTag(
|
||||||
|
entry,
|
||||||
|
"changedVersionNumber"
|
||||||
|
) === expected
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!releaseEntryExists) {
|
||||||
|
fail(
|
||||||
|
"the changelog does not contain the expected "
|
||||||
|
+ `Stable release ${expected}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
semanticVersion,
|
||||||
|
changeLogEntries: changeLogs.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArguments(argv) {
|
||||||
|
const result = {
|
||||||
|
file: DEFAULT_FILE,
|
||||||
|
expectedStableVersion:
|
||||||
|
process.env.EXPECTED_STABLE_VERSION || ""
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index++) {
|
||||||
|
if (
|
||||||
|
argv[index] === "--file"
|
||||||
|
&& argv[index + 1]
|
||||||
|
) {
|
||||||
|
result.file = path.resolve(argv[++index]);
|
||||||
|
} else if (
|
||||||
|
argv[index] === "--expected-stable"
|
||||||
|
&& argv[index + 1]
|
||||||
|
) {
|
||||||
|
result.expectedStableVersion = argv[++index];
|
||||||
|
} else {
|
||||||
|
fail(
|
||||||
|
`unknown or incomplete argument: ${argv[index]}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
try {
|
||||||
|
const options =
|
||||||
|
parseArguments(process.argv.slice(2));
|
||||||
|
|
||||||
|
const xml =
|
||||||
|
fs.readFileSync(options.file, "utf8");
|
||||||
|
|
||||||
|
const result =
|
||||||
|
validateVersionInfo(
|
||||||
|
xml,
|
||||||
|
options.expectedStableVersion
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[versionInfo validation] OK: `
|
||||||
|
+ `Stable ${result.semanticVersion}, `
|
||||||
|
+ `${result.changeLogEntries} changelog entries, `
|
||||||
|
+ options.file
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normaliseVersion,
|
||||||
|
validateVersionInfo
|
||||||
|
};
|
||||||
@@ -5,16 +5,93 @@ const REPO = "praktimarc/kst4contest";
|
|||||||
const API = `https://api.github.com/repos/${REPO}`;
|
const API = `https://api.github.com/repos/${REPO}`;
|
||||||
const LABEL_MAP = { enhancement: "added", bug: "fixed" };
|
const LABEL_MAP = { enhancement: "added", bug: "fixed" };
|
||||||
|
|
||||||
|
const GITHUB_API_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
function wait(milliseconds) {
|
||||||
|
return new Promise(
|
||||||
|
(resolve) => setTimeout(resolve, milliseconds)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function githubGet(urlPath) {
|
async function githubGet(urlPath) {
|
||||||
const headers = { Accept: "application/vnd.github+json" };
|
const headers = {
|
||||||
|
Accept: "application/vnd.github+json"
|
||||||
|
};
|
||||||
|
|
||||||
if (process.env.GITHUB_TOKEN) {
|
if (process.env.GITHUB_TOKEN) {
|
||||||
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
|
headers.Authorization =
|
||||||
|
`Bearer ${process.env.GITHUB_TOKEN}`;
|
||||||
}
|
}
|
||||||
const res = await fetch(`${API}${urlPath}`, { headers });
|
|
||||||
if (!res.ok) {
|
let lastError = null;
|
||||||
throw new Error(`GitHub API ${urlPath} failed: ${res.status}`);
|
|
||||||
|
for (
|
||||||
|
let attempt = 1;
|
||||||
|
attempt <= GITHUB_API_ATTEMPTS;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(
|
||||||
|
`${API}${urlPath}`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
signal: AbortSignal.timeout(15000)
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
return res.json();
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const rateLimitRemaining =
|
||||||
|
res.headers.get("x-ratelimit-remaining");
|
||||||
|
|
||||||
|
const rateLimitReset =
|
||||||
|
res.headers.get("x-ratelimit-reset");
|
||||||
|
|
||||||
|
const rateLimitInfo =
|
||||||
|
rateLimitRemaining === null
|
||||||
|
? ""
|
||||||
|
: `, rate limit remaining `
|
||||||
|
+ rateLimitRemaining
|
||||||
|
+ (
|
||||||
|
rateLimitReset
|
||||||
|
? `, reset ${rateLimitReset}`
|
||||||
|
: ""
|
||||||
|
);
|
||||||
|
|
||||||
|
lastError = new Error(
|
||||||
|
`GitHub API ${urlPath} failed with `
|
||||||
|
+ `HTTP ${res.status}${rateLimitInfo}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Authentication and permission errors do not
|
||||||
|
// become valid by retrying.
|
||||||
|
if (
|
||||||
|
![408, 429].includes(res.status)
|
||||||
|
&& res.status < 500
|
||||||
|
) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err;
|
||||||
|
|
||||||
|
// Do not hide an invalid or expired token behind
|
||||||
|
// repeated requests.
|
||||||
|
if (
|
||||||
|
/HTTP (401|403|404)/.test(err.message)
|
||||||
|
) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < GITHUB_API_ATTEMPTS) {
|
||||||
|
await wait(attempt * 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError
|
||||||
|
|| new Error(`GitHub API ${urlPath} failed`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateChecker.java parses <versionNumber> with Double.parseDouble() and
|
// UpdateChecker.java parses <versionNumber> with Double.parseDouble() and
|
||||||
@@ -108,25 +185,64 @@ function loadLegacySections() {
|
|||||||
// UpdateChecker.java) from GitHub releases + closed issues, falling back to
|
// UpdateChecker.java) from GitHub releases + closed issues, falling back to
|
||||||
// version-history.xml for releases that predate GitHub Releases.
|
// version-history.xml for releases that predate GitHub Releases.
|
||||||
module.exports = async function () {
|
module.exports = async function () {
|
||||||
const fallback = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n<praktiKST></praktiKST>';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historyEntries = loadHistoryEntries();
|
const historyEntries = loadHistoryEntries();
|
||||||
|
|
||||||
const rawReleases = await githubGet("/releases?per_page=100");
|
const rawReleases =
|
||||||
|
await githubGet("/releases?per_page=100");
|
||||||
|
|
||||||
|
if (!Array.isArray(rawReleases)) {
|
||||||
|
throw new Error(
|
||||||
|
"GitHub releases response was not an array"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const releases = rawReleases
|
const releases = rawReleases
|
||||||
.map((r) => ({
|
.map((release) => ({
|
||||||
tagName: r.tag_name,
|
tagName: release.tag_name,
|
||||||
publishedAt: r.published_at || "",
|
publishedAt: release.published_at || "",
|
||||||
name: r.name || r.tag_name,
|
name:
|
||||||
body: r.body || "",
|
release.name
|
||||||
isPrerelease: r.prerelease
|
|| release.tag_name,
|
||||||
|
body: release.body || "",
|
||||||
|
isPrerelease: release.prerelease,
|
||||||
|
isDraft: release.draft
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1));
|
.sort(
|
||||||
|
(first, second) =>
|
||||||
|
first.publishedAt
|
||||||
|
< second.publishedAt
|
||||||
|
? 1
|
||||||
|
: -1
|
||||||
|
);
|
||||||
|
|
||||||
|
const stableReleases = releases.filter(
|
||||||
|
(release) =>
|
||||||
|
!release.isPrerelease
|
||||||
|
&& !release.isDraft
|
||||||
|
);
|
||||||
|
|
||||||
const stableReleases = releases.filter((r) => !r.isPrerelease);
|
|
||||||
const stable = stableReleases[0];
|
const stable = stableReleases[0];
|
||||||
const ghVersions = new Set(stableReleases.map((r) => toAppVersionNumber(r.tagName)));
|
|
||||||
|
if (
|
||||||
|
!stable
|
||||||
|
|| !stable.tagName
|
||||||
|
|| !stable.publishedAt
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
"GitHub did not return "
|
||||||
|
+ "a published Stable release"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghVersions = new Set(
|
||||||
|
stableReleases.map(
|
||||||
|
(release) =>
|
||||||
|
toAppVersionNumber(
|
||||||
|
release.tagName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const parts = [];
|
const parts = [];
|
||||||
parts.push('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
|
parts.push('<?xml version="1.0" encoding="UTF-8" standalone="no"?>');
|
||||||
@@ -203,7 +319,14 @@ module.exports = async function () {
|
|||||||
parts.push("</praktiKST>");
|
parts.push("</praktiKST>");
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn(`[versionInfo] Could not generate version info XML, using empty fallback: ${err.message}`);
|
throw new Error(
|
||||||
return fallback;
|
"[versionInfo] Could not generate "
|
||||||
|
+ "a complete update feed. "
|
||||||
|
+ "The website build has been aborted "
|
||||||
|
+ "so that the existing production feed "
|
||||||
|
+ "remains untouched: "
|
||||||
|
+ err.message,
|
||||||
|
{ cause: err }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="theme-color" content="#08110b">
|
<meta name="theme-color" content="#08110b">
|
||||||
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml">
|
||||||
<link rel="icon" href="/assets/favicon.ico" sizes="any">
|
|
||||||
<meta name="description" content="{{ description or 'KST4Contest connects ON4KST chat, candidate selection, sked planning and station software for VHF, UHF and SHF contest operation.' }}">
|
<meta name="description" content="{{ description or 'KST4Contest connects ON4KST chat, candidate selection, sked planning and station software for VHF, UHF and SHF contest operation.' }}">
|
||||||
<link rel="canonical" href="https://kst4contest.hamradioonline.de{{ page.url }}">
|
<link rel="canonical" href="https://kst4contest.hamradioonline.de{{ page.url }}">
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const test = require("node:test");
|
||||||
|
|
||||||
|
const generateVersionInfo =
|
||||||
|
require("../src/_data/versionInfo");
|
||||||
|
|
||||||
|
const {
|
||||||
|
validateVersionInfo
|
||||||
|
} = require("../scripts/validate-version-info");
|
||||||
|
|
||||||
|
const VALID_XML =
|
||||||
|
`<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<praktiKST>
|
||||||
|
<latestVersion>
|
||||||
|
<versionNumber>1.411</versionNumber>
|
||||||
|
<semanticVersion>1.41.1</semanticVersion>
|
||||||
|
<adminMessage></adminMessage>
|
||||||
|
<majorChanges>Hotfix</majorChanges>
|
||||||
|
<latestVersionPathOnWebserver>https://github.com/praktimarc/kst4contest/releases/tag/v1.41.1</latestVersionPathOnWebserver>
|
||||||
|
</latestVersion>
|
||||||
|
<needUpdateSinceLastVersion>
|
||||||
|
<filename>nothing</filename>
|
||||||
|
</needUpdateSinceLastVersion>
|
||||||
|
<changeLog>
|
||||||
|
<changedVersionNumber>1.41.1</changedVersionNumber>
|
||||||
|
<date>2026-07-08</date>
|
||||||
|
<description>Hotfix</description>
|
||||||
|
<added></added>
|
||||||
|
<changed></changed>
|
||||||
|
<fixed>Text input handling</fixed>
|
||||||
|
<removed></removed>
|
||||||
|
</changeLog>
|
||||||
|
</praktiKST>`;
|
||||||
|
|
||||||
|
test("accepts a complete update feed", () => {
|
||||||
|
const result =
|
||||||
|
validateVersionInfo(VALID_XML, "v1.41.1");
|
||||||
|
|
||||||
|
assert.equal(result.semanticVersion, "1.41.1");
|
||||||
|
assert.equal(result.changeLogEntries, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects the former empty fallback", () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateVersionInfo(
|
||||||
|
'<?xml version="1.0" '
|
||||||
|
+ 'encoding="UTF-8"?>'
|
||||||
|
+ "<praktiKST></praktiKST>"
|
||||||
|
),
|
||||||
|
/empty or implausibly small/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
"rejects a feed which does not contain "
|
||||||
|
+ "the expected Stable release",
|
||||||
|
() => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
validateVersionInfo(
|
||||||
|
VALID_XML,
|
||||||
|
"v1.42.0"
|
||||||
|
),
|
||||||
|
/does not match expected release/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
"aborts generation when GitHub rejects "
|
||||||
|
+ "the API request",
|
||||||
|
async () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
global.fetch = async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
get: () => null
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(
|
||||||
|
generateVersionInfo(),
|
||||||
|
/website build has been aborted.*HTTP 401/i
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user