diff --git a/github_docs/client_settings_window_beacon.png b/github_docs/client_settings_window_beacon.png new file mode 100644 index 0000000..6a12482 Binary files /dev/null and b/github_docs/client_settings_window_beacon.png differ diff --git a/github_docs/de-Funktionen.md b/github_docs/de-Funktionen.md index a6e8d9e..5ea9b96 100644 --- a/github_docs/de-Funktionen.md +++ b/github_docs/de-Funktionen.md @@ -228,8 +228,9 @@ So kann der Contest-Operator auf einem Blick sehen, welche Stationen wann und ü ## Intervall-Beacon -Automatische CQ-Meldungen im öffentlichen Kanal in konfigurierbarem Intervall. Empfohlene Verwendung mit der Variable `MYQRG` für aktuelle Frequenzangabe. Details: [Konfiguration – Beacon Settings](Konfiguration#beacon-settings-automatischer-beacon). +KST4Contest kann wiederkehrende CQ-Nachrichten in den öffentlichen Chat senden. Beide Chat-Kategorien verwenden ein gemeinsames Intervall, besitzen aber jeweils einen eigenen Aktivierungsschalter und Nachrichtentext. Globale Variablen wie `MYQRG`, `SECONDQRG` oder `MYLOCATOR` werden unmittelbar vor jeder Aussendung aktualisiert. +Der Beacon ist für längeres CQ-Rufen auf einer festen Frequenz gedacht. Beim Absuchen oder häufigen Wechseln der QRG sollte er ausgeschaltet werden, damit keine inzwischen falsche Frequenz verbreitet wird. Details: [Konfiguration – Beacon Settings](de-Konfiguration#beacon-settings-automatischer-beacon). --- ## Simplelogfile diff --git a/github_docs/de-Konfiguration.md b/github_docs/de-Konfiguration.md index e0aa9f6..f37963a 100644 --- a/github_docs/de-Konfiguration.md +++ b/github_docs/de-Konfiguration.md @@ -183,10 +183,45 @@ Wenn in der Benutzerliste ein Rufzeichen ausgewählt ist, wird der Snippet als D ## Beacon Settings (Automatischer Beacon) -Konfiguration eines automatischen Intervall-Beacons im öffentlichen Chat-Kanal. Empfohlen: Variable `MYQRG` im Text verwenden, damit die aktuelle Frequenz immer aktuell ist. Intervall und Text sind frei konfigurierbar. +![Beacon-Einstellungen](client_settings_window_beacon.png) -> **Tipp**: Beacon beim CQ-Rufen aktivieren und im Einstellungsfenster schnell deaktivieren, wenn kein CQ gerufen wird. +Ein Beacon sendet in regelmäßigen Abständen eine öffentliche CQ-Nachricht. Er ist für Betriebssituationen gedacht, in denen die eigene Station über längere Zeit auf einer festen Frequenz ruft. Andere Stationen erhalten dadurch eine aktuelle QRG-Information, ohne dass der Operator denselben Text wiederholt von Hand in den Chat schreiben muss. +KST4Contest verwendet einen gemeinsamen Timer für beide Chat-Kategorien. Aktivierung und Nachrichtentext werden trotzdem getrennt konfiguriert: + +- **Enable CQ beacon** aktiviert den Beacon der betreffenden Kategorie. +- **Beacon message** enthält den öffentlichen Nachrichtentext dieser Kategorie. +- **Shared beacon interval** legt das gemeinsame Intervall für beide Kategorien fest. + +Sind beide Beacons aktiviert, werden sie beim selben Timer-Lauf nacheinander in ihren jeweiligen Kategorien gesendet. Der zweite Beacon wird nur berücksichtigt, wenn auch der zweite Chat aktiviert und verbunden ist. + +### Intervall und Timer-Verhalten + +Das Intervall wird in ganzen Minuten angegeben. Der kleinste zulässige Wert ist eine Minute. + +Nach dem Aufbau der Chat-Verbindung prüft KST4Contest die Beacons erstmals nach ungefähr zehn Sekunden. Anschließend gilt das eingestellte Intervall. Wird der Wert während einer laufenden Verbindung geändert, beginnt der Countdown mit dem neuen Intervall erneut. Die Änderung selbst löst keine sofortige Nachricht aus. + +### Nachrichtentext und Variablen + +Ein Beacon darf nach der Variablenauflösung höchstens 120 Zeichen enthalten. KST4Contest prüft deshalb nicht nur das eingetragene Template, sondern den tatsächlich zu sendenden Text. + +Im Beacon können alle [globalen Variablen](de-Makros-und-Variablen#variablen-im-beacon) verwendet werden, beispielsweise: + +```text +calling cq at MYQRG, ant MYQTF deg, loc MYLOCATOR +``` + +Die Variablen werden bei jedem Timer-Lauf neu aufgelöst. Ändert die Logsoftware zwischenzeitlich die in `MYQRG` gespeicherte Frequenz, verwendet bereits der nächste Beacon den neuen Wert. + +Stationsbezogene Variablen wie `QRZNAME`, `FIRSTAP` oder `SECONDAP` benötigen dagegen eine ausgewählte Gegenstation. Da ein öffentlicher Beacon keine Gegenstation adressiert, werden diese Variablen im Beacon nicht aufgelöst. + +### Wann sollte der Beacon ausgeschaltet werden? + +Der Beacon ist nur dann hilfreich, wenn seine QRG-Angabe zum tatsächlichen Betrieb passt. Bleibt er beim Absuchen oder häufigen Wechseln von Frequenzen aktiviert, können andere Stationen auf einer inzwischen falschen Frequenz nach der eigenen Station suchen. + +Im Klartext: Solange auf einer festen QRG CQ gerufen wird, spart der Beacon Arbeit. Beim „Schleichen“ über das Band sollte er ausgeschaltet werden. + +Änderungen wirken während der laufenden Verbindung. Damit Aktivierung, Texte und Intervall auch nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden. --- ## Messagehandling Settings (ab v1.25) diff --git a/github_docs/de-Makros-und-Variablen.md b/github_docs/de-Makros-und-Variablen.md index 610a44a..17cc74e 100644 --- a/github_docs/de-Makros-und-Variablen.md +++ b/github_docs/de-Makros-und-Variablen.md @@ -138,18 +138,34 @@ Wird durch die aktuelle Antennenrichtung in Worten ersetzt (z. B. `north`, `nort --- +## Variablen im Beacon ## Variablen im Beacon -Alle Variablen können auch im **automatischen Beacon** (Intervall-Nachrichten) verwendet werden. Empfohlene Beacon-Konfiguration: +Ein öffentlicher Beacon besitzt keine ausgewählte Gegenstation. Deshalb können hier ausschließlich Variablen verwendet werden, die nur von der eigenen Station und ihrer aktuellen Konfiguration abhängen: + +| Variable | Wert im Beacon | +|---|---| +| `MYQRG` | aktuelle QRG der ersten Chat-Kategorie | +| `MYQRGSHORT` | auf sieben Zeichen gekürzte QRG der ersten Kategorie | +| `SECONDQRG` | aktuelle QRG der zweiten Chat-Kategorie | +| `MYLOCATOR` | eigener vollständiger Locator | +| `MYLOCATORSHORT` | eigener vierstelliger Locator | +| `MYCALL` | eigenes Rufzeichen | +| `MYQTF` | aktuelle Antennenrichtung | + +`QRZNAME`, `FIRSTAP` und `SECONDAP` benötigen eine ausgewählte Station. In einem öffentlichen Beacon werden sie daher nicht aufgelöst. + +Eine zweckmäßige Konfiguration ist beispielsweise: ``` calling cq at MYQRG, loc MYLOCATOR, GL all! ``` -Da KST4Contest QRG-Daten automatisch aus Chat-Nachrichten ausliest: Wenn andere Stationen ebenfalls KST4Contest nutzen, sehen sie die eigene QRG sofort in der QRG-Spalte der Benutzerliste. +Die globalen Variablen werden bei jedem Timer-Lauf neu ausgewertet. Dadurch kann eine vom Logprogramm aktualisierte QRG bereits in der nächsten Beacon-Nachricht erscheinen. + +Der vollständig aufgelöste Nachrichtentext darf höchstens 120 Zeichen enthalten. Weitere Angaben zum gemeinsamen Intervall und zum Verhalten beider Chat-Kategorien stehen unter [Konfiguration – Beacon Settings](de-Konfiguration#beacon-settings-automatischer-beacon). --- - ## Beispiel-Workflow mit Makros im Contest 1. Station in der Benutzerliste auswählen → Rufzeichen ist nun vorausgewählt. diff --git a/pom.xml b/pom.xml index 58441d3..ae50061 100644 --- a/pom.xml +++ b/pom.xml @@ -150,20 +150,32 @@ ${jetbrains.annotations.version} compile - - + diff --git a/src/main/java/kst4contest/controller/BeaconTask.java b/src/main/java/kst4contest/controller/BeaconTask.java index 5dd0e74..8ce7388 100644 --- a/src/main/java/kst4contest/controller/BeaconTask.java +++ b/src/main/java/kst4contest/controller/BeaconTask.java @@ -1,122 +1,178 @@ package kst4contest.controller; -import java.util.Arrays; import java.util.TimerTask; import kst4contest.model.ChatMessage; import kst4contest.model.ThreadStateMessage; /** - * This class is for sending beacons intervalled to the public chat. Gets all - * preferences and instances via the Chatpreferences-object of the - * Chatcontroller. - *

- * The task will be runned out of the singleton ChatController instance in an - * intervall as specified by the Chatpreferences-instance (typically as - * configured in the xml file. - * - * - * @author prakt + * Sends the configured public-chat beacons for both active chat categories. * + *

Both categories deliberately share one timer and therefore the same + * interval. Their enable flags and message templates remain independent. Every + * run reads the current preferences, resolves global message variables and + * sends only the categories which are currently enabled.

*/ public class BeaconTask extends TimerTask { - private ChatController chatController; - private ThreadStatusCallback callBackToController; - private String ThreadNickName = "MyBeacon"; + private static final String THREAD_NICKNAME = "MyBeacon"; - public BeaconTask(ChatController client, ThreadStatusCallback callback) { - this.callBackToController = callback; - this.chatController = client; + private final ChatController chatController; + private final ThreadStatusCallback callbackToController; + /** + * Creates one execution of the shared beacon timer. + * + * @param chatController controller providing preferences and the TX queue + * @param callbackToController callback used by the thread-status display + */ + public BeaconTask( + ChatController chatController, + ThreadStatusCallback callbackToController + ) { + this.chatController = chatController; + this.callbackToController = callbackToController; } @Override public void run() { + Thread.currentThread().setName("BeaconTask"); + reportStatus(THREAD_NICKNAME, true, "initialized", false); - ThreadStateMessage threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "initialized", false); - callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - - Thread.currentThread().setName("BeaconTask"); - - - ChatMessage beaconMSG = new ChatMessage(); - - String replaceVariables = this.chatController.getChatPreferences().getBcn_beaconTextMainCat(); - - replaceVariables = replaceVariables.replaceAll("MYQRG", this.chatController.getChatPreferences().getMYQRGFirstCat().getValue()); - replaceVariables = replaceVariables.replaceAll("MYCALL", this.chatController.getChatPreferences().getStn_loginCallSign()); - replaceVariables = replaceVariables.replaceAll("MYLOCATOR", this.chatController.getChatPreferences().getStn_loginLocatorMainCat()); - replaceVariables = replaceVariables.replaceAll("MYQTF", this.chatController.getChatPreferences().getActualQTF().getValue() + ""); - replaceVariables = replaceVariables.replaceAll("SECONDQRG", this.chatController.getChatPreferences().getMYQRGSecondCat().getValue() + ""); - - - - beaconMSG.setMessageText( - "MSG|" + this.chatController.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|" + replaceVariables + "|0|"); - beaconMSG.setMessageDirectedToServer(true); - - - - - ChatMessage beaconMSG2 = new ChatMessage(); - - String replaceVariables2 = this.chatController.getChatPreferences().getBcn_beaconTextSecondCat(); - - replaceVariables2 = replaceVariables2.replaceAll("MYQRG", this.chatController.getChatPreferences().getMYQRGFirstCat().getValue()); - replaceVariables2 = replaceVariables2.replaceAll("MYCALL", this.chatController.getChatPreferences().getStn_loginCallSign()); - replaceVariables2 = replaceVariables2.replaceAll("MYLOCATOR", this.chatController.getChatPreferences().getStn_loginLocatorMainCat()); - replaceVariables2 = replaceVariables2.replaceAll("MYQTF", this.chatController.getChatPreferences().getActualQTF().getValue() + ""); - replaceVariables2 = replaceVariables2.replaceAll("SECONDQRG", this.chatController.getChatPreferences().getMYQRGSecondCat().getValue() + ""); - - - beaconMSG2.setMessageText( - "MSG|" + this.chatController.getChatPreferences().getLoginChatCategorySecond().getCategoryNumber() + "|0|" + replaceVariables + "|0|"); - beaconMSG2.setMessageDirectedToServer(true); - - - - /** - * beacon 1st Chatcategory - */ - if (this.chatController.getChatPreferences().isBcn_beaconsEnabledMainCat() ) { - - System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() - + " [BeaconTask, Info]: Sending CQ: " + beaconMSG.getMessageText()); - this.chatController.getMessageTXBus().add(beaconMSG); - - threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 1", true, "on", false); - callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - - } else { - threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 1", false, "off", false); - callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - } - - /** - * beacon 2nd Chatcategory - */ - if (this.chatController.getChatPreferences().isLoginToSecondChatEnabled()) { //only send if 2nd cat enabled - - if (this.chatController.getChatPreferences().isBcn_beaconsEnabledSecondCat()) { - - beaconMSG2.setMessageText( - "MSG|" + this.chatController.getChatPreferences().getLoginChatCategorySecond().getCategoryNumber() + "|0|" + replaceVariables2 + "|0|"); - beaconMSG2.setMessageDirectedToServer(true); - - System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() - + " [BeaconTask, Info]: Sending CQ 2nd Cat: " + beaconMSG2.getMessageText()); - this.chatController.getMessageTXBus().add(beaconMSG2); - - threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 2", true, "on", false); - callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - - } else { - threadStateMessage = new ThreadStateMessage(this.ThreadNickName + " 2", false, "off", false); - callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - } - } + MessageVariableResolver variableResolver = + new MessageVariableResolver(chatController.getChatPreferences()); + sendMainCategoryBeacon(variableResolver); + sendSecondCategoryBeacon(variableResolver); } -} + /** + * Sends the main-category beacon if it is currently enabled. + */ + private void sendMainCategoryBeacon(MessageVariableResolver variableResolver) { + if (!chatController.getChatPreferences().isBcn_beaconsEnabledMainCat()) { + reportStatus(THREAD_NICKNAME + " 1", false, "off", false); + return; + } + + String resolvedText = variableResolver.resolveGlobalVariables( + chatController.getChatPreferences().getBcn_beaconTextMainCat() + ); + ChatMessage beaconMessage = buildBeaconMessage( + chatController.getChatPreferences() + .getLoginChatCategoryMain() + .getCategoryNumber(), + resolvedText, + "main category" + ); + + if (beaconMessage == null) { + reportStatus(THREAD_NICKNAME + " 1", false, "invalid text", true); + return; + } + + System.out.println( + new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + + " [BeaconTask, Info]: Sending main-category CQ: " + + beaconMessage.getMessageText() + ); + chatController.getMessageTXBus().add(beaconMessage); + reportStatus(THREAD_NICKNAME + " 1", true, "on", false); + } + + /** + * Sends the second-category beacon if the second login and its beacon are + * currently enabled. + */ + private void sendSecondCategoryBeacon( + MessageVariableResolver variableResolver + ) { + if (!chatController.getChatPreferences().isLoginToSecondChatEnabled() + || !chatController.getChatPreferences() + .isBcn_beaconsEnabledSecondCat()) { + reportStatus(THREAD_NICKNAME + " 2", false, "off", false); + return; + } + + String resolvedText = variableResolver.resolveGlobalVariables( + chatController.getChatPreferences().getBcn_beaconTextSecondCat() + ); + ChatMessage beaconMessage = buildBeaconMessage( + chatController.getChatPreferences() + .getLoginChatCategorySecond() + .getCategoryNumber(), + resolvedText, + "second category" + ); + + if (beaconMessage == null) { + reportStatus(THREAD_NICKNAME + " 2", false, "invalid text", true); + return; + } + + System.out.println( + new Utils4KST().time_generateCurrentMMDDhhmmTimeString() + + " [BeaconTask, Info]: Sending second-category CQ: " + + beaconMessage.getMessageText() + ); + chatController.getMessageTXBus().add(beaconMessage); + reportStatus(THREAD_NICKNAME + " 2", true, "on", false); + } + + /** + * Builds the server-directed message after validating the resolved payload. + * + *

The resolved text is checked rather than only the configured template + * because inserted values can increase the final message length.

+ * + * @param categoryNumber ON4KST category number + * @param resolvedText fully resolved beacon payload + * @param categoryDescription text used in diagnostic output + * @return prepared message, or {@code null} if the payload is invalid + */ + private ChatMessage buildBeaconMessage( + int categoryNumber, + String resolvedText, + String categoryDescription + ) { + if (resolvedText == null + || resolvedText.length() > ChatController.MAX_BEACON_TEXT_LENGTH) { + int actualLength = resolvedText == null ? 0 : resolvedText.length(); + System.out.println( + "[BeaconTask, Warning]: Beacon for " + + categoryDescription + + " was not sent because the resolved text contains " + + actualLength + + " characters; maximum is " + + ChatController.MAX_BEACON_TEXT_LENGTH + + "." + ); + return null; + } + + ChatMessage beaconMessage = new ChatMessage(); + beaconMessage.setMessageText( + "MSG|" + categoryNumber + "|0|" + resolvedText + "|0|" + ); + beaconMessage.setMessageDirectedToServer(true); + return beaconMessage; + } + + /** + * Forwards one state update to the existing thread-status display. + */ + private void reportStatus( + String threadName, + boolean running, + String information, + boolean criticalState + ) { + ThreadStateMessage stateMessage = new ThreadStateMessage( + threadName, + running, + information, + criticalState + ); + callbackToController.onThreadStatus(THREAD_NICKNAME, stateMessage); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/ChatController.java b/src/main/java/kst4contest/controller/ChatController.java index 8c959d6..2b3a9c5 100644 --- a/src/main/java/kst4contest/controller/ChatController.java +++ b/src/main/java/kst4contest/controller/ChatController.java @@ -59,6 +59,10 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList private static final boolean DEBUG_BAND_UPGRADE_HINT = true; //for new band hint + public static final int MIN_BEACON_INTERVAL_MINUTES = 1; + public static final int MAX_BEACON_TEXT_LENGTH = 120; + private static final long INITIAL_BEACON_DELAY_MILLIS = 10_000L; + private PstRotatorClient rotatorClient; private Consumer viewRotorCallback; @@ -708,8 +712,7 @@ public class ChatController implements ThreadStatusCallback, PstRotatorEventList // writeThread.interrupt(); // readThread.interrupt(); - beaconTimer.purge(); - beaconTimer.cancel(); + stopBeaconTimer(); ASQueryTimer.purge(); ASQueryTimer.cancel(); socketCheckTimer.purge(); @@ -2239,6 +2242,78 @@ private ObservableList this.dbHandler = dbHandler; } + /** + * Starts the shared beacon timer with the interval currently stored in the + * preferences. + * + *

Both chat categories deliberately use the same timer. The task checks the + * individual enable flag and text for each category every time it runs.

+ * + * @param initialDelayMillis delay before the next beacon run + */ + private synchronized void scheduleBeaconTimer(long initialDelayMillis) { + stopBeaconTimer(); + + int configuredInterval = + chatPreferences.getBcn_beaconIntervalInMinutesMainCat(); + int effectiveInterval = + Math.max(MIN_BEACON_INTERVAL_MINUTES, configuredInterval); + + /* + * Keep both legacy XML values synchronized. The second value remains in the + * configuration for backward compatibility, but no longer represents an + * independent timer. + */ + chatPreferences.setBcn_beaconIntervalInMinutesMainCat(effectiveInterval); + chatPreferences.setBcn_beaconIntervalInMinutesSecondCat(effectiveInterval); + + long intervalMillis = TimeUnit.MINUTES.toMillis(effectiveInterval); + long safeInitialDelay = Math.max(0L, initialDelayMillis); + + beaconTimer = new Timer("BeaconTimer"); + beaconTimer.schedule( + new BeaconTask(this, this), + safeInitialDelay, + intervalMillis + ); + + System.out.println("[ChatController, Info]: Shared beacon timer scheduled every " + + effectiveInterval + " minute(s)."); + } + + /** + * Applies a changed beacon interval while the chat connection is running. + * + *

The countdown starts again with the new interval. No beacon is sent + * immediately merely because the setting was changed.

+ */ + public synchronized void restartBeaconTimer() { + if (!isConnectedAndLoggedIn()) { + return; + } + + long intervalMillis = TimeUnit.MINUTES.toMillis( + Math.max( + MIN_BEACON_INTERVAL_MINUTES, + chatPreferences.getBcn_beaconIntervalInMinutesMainCat() + ) + ); + scheduleBeaconTimer(intervalMillis); + } + + /** + * Stops the shared beacon timer if it is currently running. + */ + private synchronized void stopBeaconTimer() { + if (beaconTimer == null) { + return; + } + + beaconTimer.cancel(); + beaconTimer.purge(); + beaconTimer = null; + } + /** * execute is the main entry point where the application starts. * @throws InterruptedException @@ -2338,11 +2413,7 @@ private ObservableList * The CQ-beacon-Task will be executed every time but checks for itself whether * CQ messages are enabled or not */ -// Timer beaconTimer; - beaconTimer = new Timer(); - beaconTimer.schedule(new BeaconTask(this, this), 10000, - this.getChatPreferences().getBcn_beaconIntervalInMinutesMainCat() * 60000); - // 60000 * intervalInMinutes = IntervalInMillis + scheduleBeaconTimer(INITIAL_BEACON_DELAY_MILLIS); /** * The AS querier task will be executed every time but checks for itself whether diff --git a/src/main/java/kst4contest/controller/MessageVariableResolver.java b/src/main/java/kst4contest/controller/MessageVariableResolver.java new file mode 100644 index 0000000..9a0033c --- /dev/null +++ b/src/main/java/kst4contest/controller/MessageVariableResolver.java @@ -0,0 +1,154 @@ +package kst4contest.controller; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Objects; + +import kst4contest.model.AirPlane; +import kst4contest.model.AirPlaneReflectionInfo; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatPreferences; + +/** + * Resolves variables used in operator messages, shortcuts, snippets and + * beacons. + * + *

Global variables only depend on the local station configuration and can + * therefore be used in every message context. Station variables additionally + * require a selected remote station. Keeping both groups in one resolver + * prevents the send field and the beacon tasks from implementing different + * replacement rules.

+ */ +public final class MessageVariableResolver { + + private static final int SHORT_VALUE_LENGTH = 7; + + private final ChatPreferences chatPreferences; + + /** + * Creates a resolver backed by the live application preferences. + * + * @param chatPreferences preferences that provide current station values + */ + public MessageVariableResolver(ChatPreferences chatPreferences) { + this.chatPreferences = Objects.requireNonNull(chatPreferences, "chatPreferences"); + } + + /** + * Resolves variables which do not require a selected remote station. + * + *

The replacement is literal rather than regular-expression based. + * Callsigns, locators and frequencies are data, not regular expressions.

+ * + * @param template text that may contain variables + * @return text with all available global variables resolved, or {@code null} + * when the supplied template is {@code null} + */ + public String resolveGlobalVariables(String template) { + if (template == null) { + return null; + } + + String primaryFrequency = valueOrEmpty(chatPreferences.getMYQRGFirstCat().getValue()); + String secondaryFrequency = valueOrEmpty(chatPreferences.getMYQRGSecondCat().getValue()); + String ownLocator = valueOrEmpty(chatPreferences.getStn_loginLocatorMainCat()); + + String resolvedText = template; + resolvedText = resolvedText.replace("MYQRGSHORT", abbreviate(primaryFrequency)); + resolvedText = resolvedText.replace("MYQRG", primaryFrequency); + resolvedText = resolvedText.replace("SECONDQRG", secondaryFrequency); + resolvedText = resolvedText.replace("MYLOCATORSHORT", abbreviateLocator(ownLocator)); + resolvedText = resolvedText.replace("MYLOCATOR", ownLocator); + resolvedText = resolvedText.replace("MYCALL", valueOrEmpty(chatPreferences.getStn_loginCallSign())); + resolvedText = resolvedText.replace("MYQTF", formatHeading(chatPreferences.getActualQTF().getValue().doubleValue())); + return resolvedText; + } + + /** + * Resolves global variables and variables derived from a selected station. + * + *

If no station is selected, station-specific placeholders remain visible. + * This is intentional: silently removing {@code QRZNAME}, {@code FIRSTAP} or + * {@code SECONDAP} could create a plausible-looking but incomplete message.

+ * + * @param template text that may contain variables + * @param selectedStation currently selected remote station, may be {@code null} + * @return resolved message text + */ + public String resolveForSelectedStation(String template, ChatMember selectedStation) { + String resolvedText = resolveGlobalVariables(template); + + if (resolvedText == null || selectedStation == null) { + return resolvedText; + } + + resolvedText = resolvedText.replace("QRZNAME", resolveStationName(selectedStation)); + resolvedText = resolvedText.replace("FIRSTAP", resolveFirstAirPlane(selectedStation)); + resolvedText = resolvedText.replace("SECONDAP", resolveSecondAirPlane(selectedStation)); + return resolvedText; + } + + private String resolveStationName(ChatMember selectedStation) { + String stationName = valueOrEmpty(selectedStation.getName()).trim(); + + if (!stationName.isEmpty()) { + return stationName; + } + + return valueOrEmpty(selectedStation.getCallSign()); + } + + private String resolveFirstAirPlane(ChatMember selectedStation) { + List risingAirPlanes = getRisingAirPlanes(selectedStation); + + if (risingAirPlanes.isEmpty()) { + return "no ap available"; + } + + AirPlane firstAirPlane = risingAirPlanes.get(0); + return "a " + firstAirPlane.getPotencialDescriptionAsWord() + + " in " + firstAirPlane.getArrivingDurationMinutes() + " min"; + } + + private String resolveSecondAirPlane(ChatMember selectedStation) { + List risingAirPlanes = getRisingAirPlanes(selectedStation); + + if (risingAirPlanes.size() < 2) { + return ""; + } + + AirPlane secondAirPlane = risingAirPlanes.get(1); + return "Next " + secondAirPlane.getPotencialDescriptionAsWord() + + " in " + secondAirPlane.getArrivingDurationMinutes() + " min"; + } + + private List getRisingAirPlanes(ChatMember selectedStation) { + AirPlaneReflectionInfo reflectionInfo = selectedStation.getAirPlaneReflectInfo(); + + if (reflectionInfo == null || reflectionInfo.getRisingAirplanes() == null) { + return List.of(); + } + + return reflectionInfo.getRisingAirplanes(); + } + + private String abbreviate(String value) { + return value.substring(0, Math.min(value.length(), SHORT_VALUE_LENGTH)); + } + + private String abbreviateLocator(String locator) { + return locator.substring(0, Math.min(locator.length(), 4)); + } + + private String formatHeading(double headingDegrees) { + if (!Double.isFinite(headingDegrees)) { + return ""; + } + + return BigDecimal.valueOf(headingDegrees).stripTrailingZeros().toPlainString(); + } + + private String valueOrEmpty(String value) { + return value == null ? "" : value; + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java b/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java index f8bc734..0cdabcb 100644 --- a/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java +++ b/src/main/java/kst4contest/controller/ScoreboardUpdateTask.java @@ -14,8 +14,8 @@ import java.util.TimerTask; * The task will be runned out of the singleton ChatController instance in an * intervall as specified by the Chatpreferences-instance (typically as * configured in the xml file. - * - * + * + * * @author prakt * */ @@ -34,22 +34,20 @@ public class ScoreboardUpdateTask extends TimerTask { Thread.currentThread().setName("BeaconTask"); ChatMessage beaconMSG = new ChatMessage(); - - String replaceVariables = this.chatController.getChatPreferences().getBcn_beaconTextMainCat(); -// replaceVariables = bcn_beaconText; - - replaceVariables = replaceVariables.replaceAll("MYQRG", this.chatController.getChatPreferences().getMYQRGFirstCat().getValue()); - replaceVariables = replaceVariables.replaceAll("MYCALL", this.chatController.getChatPreferences().getStn_loginCallSign()); - replaceVariables = replaceVariables.replaceAll("MYLOCATOR", this.chatController.getChatPreferences().getStn_loginLocatorMainCat()); - replaceVariables = replaceVariables.replaceAll("MYQTF", this.chatController.getChatPreferences().getActualQTF().getValue() + ""); - + MessageVariableResolver variableResolver = + new MessageVariableResolver(this.chatController.getChatPreferences()); + String replaceVariables = variableResolver.resolveGlobalVariables( + this.chatController.getChatPreferences().getBcn_beaconTextMainCat() + ); + + beaconMSG.setMessageText( "MSG|" + this.chatController.getChatPreferences().getLoginChatCategoryMain().getCategoryNumber() + "|0|" + replaceVariables + "|0|"); beaconMSG.setMessageDirectedToServer(true); - + // System.out.println("########### " + replaceVariables); - + if (this.chatController.getChatPreferences().isBcn_beaconsEnabledMainCat() ) { System.out.println(new Utils4KST().time_generateCurrentMMDDhhmmTimeString() @@ -58,8 +56,8 @@ public class ScoreboardUpdateTask extends TimerTask { } else { //do nothing, CQ is disabled } - + } -} +} \ No newline at end of file diff --git a/src/main/java/kst4contest/test/MessageVariableResolverTest.java b/src/main/java/kst4contest/test/MessageVariableResolverTest.java new file mode 100644 index 0000000..92442ed --- /dev/null +++ b/src/main/java/kst4contest/test/MessageVariableResolverTest.java @@ -0,0 +1,112 @@ +package kst4contest.test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.collections.FXCollections; +import kst4contest.controller.MessageVariableResolver; +import kst4contest.model.AirPlane; +import kst4contest.model.AirPlaneReflectionInfo; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatPreferences; + +class MessageVariableResolverTest { + + private ChatPreferences chatPreferences; + private MessageVariableResolver resolver; + + @BeforeEach + void setUp() { + chatPreferences = mock(ChatPreferences.class); + + when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144.388.03")); + when(chatPreferences.getMYQRGSecondCat()).thenReturn(new SimpleStringProperty("432.088.00")); + when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO51IJ"); + when(chatPreferences.getStn_loginCallSign()).thenReturn("DO5AMF"); + when(chatPreferences.getActualQTF()).thenReturn(new SimpleDoubleProperty(135.0)); + + resolver = new MessageVariableResolver(chatPreferences); + } + + @Test + void resolvesAllGlobalVariablesInEveryMessageContext() { + String template = "MYCALL MYQRGSHORT MYQRG SECONDQRG MYLOCATORSHORT MYLOCATOR MYQTF"; + + assertEquals( + "DO5AMF 144.388 144.388.03 432.088.00 JO51 JO51IJ 135", + resolver.resolveGlobalVariables(template) + ); + } + + @Test + void shortValuesRemainSafeWhenTheConfiguredValueIsShorterThanExpected() { + when(chatPreferences.getMYQRGFirstCat()).thenReturn(new SimpleStringProperty("144")); + when(chatPreferences.getStn_loginLocatorMainCat()).thenReturn("JO5"); + + assertEquals("144 JO5", resolver.resolveGlobalVariables("MYQRGSHORT MYLOCATORSHORT")); + } + + @Test + void resolvesSelectedStationNameAndTheFirstTwoAirPlanes() { + ChatMember selectedStation = new ChatMember(); + selectedStation.setCallSign("DL0TEST"); + selectedStation.setName("Test Operator"); + + AirPlane firstAirPlane = new AirPlane(); + firstAirPlane.setPotential(100); + firstAirPlane.setArrivingDurationMinutes(1); + + AirPlane secondAirPlane = new AirPlane(); + secondAirPlane.setPotential(75); + secondAirPlane.setArrivingDurationMinutes(9); + + AirPlaneReflectionInfo reflectionInfo = new AirPlaneReflectionInfo(); + reflectionInfo.setRisingAirplanes(FXCollections.observableArrayList(firstAirPlane, secondAirPlane)); + selectedStation.setAirPlaneReflectInfo(reflectionInfo); + + assertEquals( + "Hi Test Operator, a very big AP in 1 min; Next big AP in 9 min", + resolver.resolveForSelectedStation( + "Hi QRZNAME, FIRSTAP; SECONDAP", + selectedStation + ) + ); + } + + @Test + void usesCallsignWhenTheSelectedStationHasNoName() { + ChatMember selectedStation = new ChatMember(); + selectedStation.setCallSign("DL0TEST"); + selectedStation.setName(" "); + + assertEquals( + "Hi DL0TEST", + resolver.resolveForSelectedStation("Hi QRZNAME", selectedStation) + ); + } + + @Test + void keepsStationVariablesVisibleWhenNoStationIsSelected() { + assertEquals( + "QRZNAME FIRSTAP SECONDAP", + resolver.resolveForSelectedStation("QRZNAME FIRSTAP SECONDAP", null) + ); + } + + @Test + void returnsUsefulFallbacksWhenNoAirPlaneIsAvailable() { + ChatMember selectedStation = new ChatMember(); + selectedStation.setCallSign("DL0TEST"); + + assertEquals( + "no ap available ", + resolver.resolveForSelectedStation("FIRSTAP SECONDAP", selectedStation) + ); + } +} \ No newline at end of file diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index 9042978..1c65844 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -33,6 +33,7 @@ import javafx.scene.media.MediaPlayer; import javafx.util.Duration; import kst4contest.ApplicationConstants; import kst4contest.controller.ChatController; +import kst4contest.controller.MessageVariableResolver; import kst4contest.controller.StatusUpdateListener; import kst4contest.controller.Utils4KST; import javafx.application.Application; @@ -156,6 +157,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL String chatState; ChatController chatcontroller; + MessageVariableResolver messageVariableResolver; Button MYQRGButton; // TODO: clean code? Got the myqrg button out of the factory method to modify @@ -2555,10 +2557,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL String string = (String) iterator.next(); final MenuItem menuItem = new MenuItem(string); menuItem.setOnAction(new EventHandler() { + @Override public void handle(ActionEvent event) { - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText() + menuItem.getText()); - txt_chatMessageUserInput.requestFocus(); - txt_chatMessageUserInput.selectEnd(); + appendResolvedMessageText(menuItem.getText()); } }); @@ -3852,15 +3853,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public void handle(CellEditEvent t) { String newValue = t.getNewValue(); - t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); - - if (newValue == "") { // delete lines which had been cleared + if (newValue == null || newValue.isBlank()) { t.getTableView().getItems().remove(t.getTablePosition().getRow()); + } else { + t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); } - flwPane_textSnippets.getChildren().clear(); - flwPane_textSnippets.getChildren() - .addAll(buttonFactory(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList())); + refreshShortcutButtons(); } }); @@ -4500,18 +4499,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL TableView tbl_txtSnips = new TableView(); tbl_txtSnips.setTooltip(new Tooltip("Personalize your textsnippets here")); -// TableColumn idCol = new TableColumn("Index"); -// idCol.setCellValueFactory(new Callback, ObservableValue>() { -// -// @Override -// public ObservableValue call(CellDataFeatures cellDataFeatures) { -// int index = 0; -// -//// index.setValue(cellDataFeatures.getValue().); -// -// return (index); -// } -// }); TableColumn snipCol = new TableColumn("Snippet"); snipCol.setCellValueFactory(new Callback, ObservableValue>() { @@ -4533,21 +4520,13 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL public void handle(CellEditEvent t) { String newValue = t.getNewValue(); - t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); - - if (newValue == "") { // delete lines which had been cleared + if (newValue == null || newValue.isBlank()) { t.getTableView().getItems().remove(t.getTablePosition().getRow()); + } else { + t.getTableView().getItems().set(t.getTablePosition().getRow(), newValue); } - chatMessageContextMenu = initChatMemberTableContextMenu( - chatcontroller.getChatPreferences().getLst_txtSnipList()); // TODO: thats not - // clean, there had - // to be a listener - // triggered update - // method - chatMemberContextMenu = initChatMemberTableContextMenu( - chatcontroller.getChatPreferences().getLst_txtSnipList()); - + refreshTextSnippetContextMenus(); } }); @@ -4563,6 +4542,140 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return tbl_txtSnips; } + /** + * Rebuilds the shortcut button row after a shortcut was edited or moved. + */ + private void refreshShortcutButtons() { + if (flwPane_textSnippets == null) { + return; + } + + flwPane_textSnippets.getChildren().setAll( + buttonFactory(chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()) + ); + } + + /** + * Rebuilds every context menu which exposes the configured text snippets. + */ + private void refreshTextSnippetContextMenus() { + ObservableList snippets = chatcontroller.getChatPreferences().getLst_txtSnipList(); + chatMessageContextMenu = initChatMemberTableContextMenu(snippets); + chatMemberContextMenu = initChatMemberTableContextMenu(snippets); + } + + /** + * Moves the selected table entry by one position. + * + * @param tableView table that owns the ordered list + * @param offset {@code -1} for up or {@code 1} for down + * @return {@code true} if an item was moved + */ + private boolean moveSelectedTableEntry(TableView tableView, int offset) { + int currentIndex = tableView.getSelectionModel().getSelectedIndex(); + int targetIndex = currentIndex + offset; + + if (currentIndex < 0 || targetIndex < 0 || targetIndex >= tableView.getItems().size()) { + return false; + } + + String selectedEntry = tableView.getItems().remove(currentIndex); + tableView.getItems().add(targetIndex, selectedEntry); + tableView.getSelectionModel().clearAndSelect(targetIndex); + tableView.scrollTo(targetIndex); + return true; + } + + + /** + * Validates and stores one beacon text. + * + *

The final text is checked after global variables have been resolved. + * Otherwise a template with at most 120 characters could still exceed the + * server limit after values such as MYQRG or MYLOCATOR were inserted.

+ * + * @param textField field containing the configured beacon template + * @param mainCategory {@code true} for the main category, {@code false} for + * the optional second category + */ + private void applyBeaconTextSetting(TextField textField, boolean mainCategory) { + String configuredText = textField.getText() == null ? "" : textField.getText(); + String resolvedText = messageVariableResolver.resolveGlobalVariables(configuredText); + + if (resolvedText != null + && resolvedText.length() <= ChatController.MAX_BEACON_TEXT_LENGTH) { + if (mainCategory) { + chatcontroller.getChatPreferences().setBcn_beaconTextMainCat(configuredText); + } else { + chatcontroller.getChatPreferences().setBcn_beaconTextSecondCat(configuredText); + } + return; + } + + String previousText = mainCategory + ? chatcontroller.getChatPreferences().getBcn_beaconTextMainCat() + : chatcontroller.getChatPreferences().getBcn_beaconTextSecondCat(); + + textField.setText(previousText); + alertWindowEvent( + "The resolved beacon message must not exceed " + + ChatController.MAX_BEACON_TEXT_LENGTH + + " characters." + ); + } + + /** + * Validates and applies the shared interval used by both beacon categories. + * + *

Only whole minutes are accepted. If the application is connected, changing + * the value restarts the shared timer and begins a new countdown with the + * selected interval.

+ * + * @param intervalField field containing the interval in minutes + */ + private void applySharedBeaconInterval(TextField intervalField) { + String enteredValue = + intervalField.getText() == null ? "" : intervalField.getText().trim(); + + try { + int intervalMinutes = Integer.parseInt(enteredValue); + + if (intervalMinutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) { + throw new NumberFormatException("Beacon interval below minimum"); + } + + chatcontroller.getChatPreferences() + .setBcn_beaconIntervalInMinutesMainCat(intervalMinutes); + + /* + * Preserve the legacy second-category XML value, but keep it synchronized + * with the one shared interval. + */ + chatcontroller.getChatPreferences() + .setBcn_beaconIntervalInMinutesSecondCat(intervalMinutes); + + intervalField.setText(Integer.toString(intervalMinutes)); + chatcontroller.restartBeaconTimer(); + + System.out.println("[Main.java, Info]: Shared beacon interval set to " + + intervalMinutes + " minute(s)."); + } catch (NumberFormatException exception) { + int currentInterval = Math.max( + ChatController.MIN_BEACON_INTERVAL_MINUTES, + chatcontroller.getChatPreferences() + .getBcn_beaconIntervalInMinutesMainCat() + ); + + intervalField.setText(Integer.toString(currentInterval)); + alertWindowEvent( + "Enter a whole beacon interval of at least " + + ChatController.MIN_BEACON_INTERVAL_MINUTES + + " minute." + ); + } + } + + private TableView initWkdStnTable() { TableView tbl_chatMemberWkdDBTable = new TableView(); @@ -5476,10 +5589,11 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } else { // System.out.println("Button clicked " + ((Button) arg0.getSource()).getText()); - txt_chatMessageUserInput.setText( - txt_chatMessageUserInput.getText() + ((Button) arg0.getSource()).getText() + " "); + appendResolvedMessageText( + ((Button) arg0.getSource()).getText() + " " + ); - } + } } }); @@ -5935,7 +6049,8 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL ChatMember ownChatMemberObject = new ChatMember(); chatcontroller = new ChatController(ownChatMemberObject, this); // instantiate the Chatcontroller with the user object - chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual + messageVariableResolver = new MessageVariableResolver(chatcontroller.getChatPreferences()); + chatcontroller.setStatusListener(this); //callback interface for updating Thread events in visual // 1. Timeline an die Sked-Liste binden chatcontroller.getActiveSkeds().addListener((ListChangeListener) c -> { @@ -6087,86 +6202,31 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } }); - scn_ChatwindowMainScene.setOnKeyPressed(new EventHandler() { - KeyCombination keyComboSTRGplus1 = new KeyCodeCombination(KeyCode.DIGIT1, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus2 = new KeyCodeCombination(KeyCode.DIGIT2, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus3 = new KeyCodeCombination(KeyCode.DIGIT3, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus4 = new KeyCodeCombination(KeyCode.DIGIT4, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus5 = new KeyCodeCombination(KeyCode.DIGIT5, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus6 = new KeyCodeCombination(KeyCode.DIGIT6, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus7 = new KeyCodeCombination(KeyCode.DIGIT7, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus8 = new KeyCodeCombination(KeyCode.DIGIT8, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus9 = new KeyCodeCombination(KeyCode.DIGIT9, KeyCombination.CONTROL_DOWN); - KeyCombination keyComboSTRGplus0 = new KeyCodeCombination(KeyCode.DIGIT0, KeyCombination.CONTROL_DOWN); - - - @Override - public void handle(KeyEvent keyEvent) { - try { - -// System.out.println(keyEvent.getCode()); - - /** - * if a macro is set by hitting strg+Nr, it should be possible to send the message by hit the enter key - */ - if (keyEvent.getCode() == KeyCode.ENTER) { - - sendButton.fire(); - - } else if (keyEvent.getCode() == KeyCode.ESCAPE) { - txt_chatMessageUserInput.clear(); - } else - - if (selectedCallSignInfoStageChatMember.getCallSign() != null) { - - if (keyComboSTRGplus1.match(keyEvent)) { - - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(0)); - - } else if (keyComboSTRGplus2.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(1)); - - } else if (keyComboSTRGplus3.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(2)); - - } else if (keyComboSTRGplus4.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(3)); - - } else if (keyComboSTRGplus5.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(4)); - - } else if (keyComboSTRGplus6.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(5)); - - } else if (keyComboSTRGplus7.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(6)); - - } else if (keyComboSTRGplus8.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(7)); - - } else if (keyComboSTRGplus9.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(8)); - - } else if (keyComboSTRGplus0.match(keyEvent)) { - txt_chatMessageUserInput.setText("/cq " + selectedCallSignInfoStageChatMember.getCallSign() + " " + chatcontroller.getChatPreferences().getLst_txtSnipList().get(9)); - - } - txt_chatMessageUserInput.requestFocus(); //in every case, focus the textfield for further edits - txt_chatMessageUserInput.selectEnd(); - } - } catch (Exception nullPointerExc) { - System.out.println("There are no predifined textsnippets for this keycombo! -> " + nullPointerExc.getMessage()); - } + scn_ChatwindowMainScene.setOnKeyPressed(keyEvent -> { + if (keyEvent.getCode() == KeyCode.ENTER) { + sendButton.fire(); + keyEvent.consume(); + return; } + + if (keyEvent.getCode() == KeyCode.ESCAPE) { + txt_chatMessageUserInput.clear(); + keyEvent.consume(); + return; + } + + int snippetIndex = resolveSnippetIndex(keyEvent); + if (snippetIndex < 0) { + return; + } + + insertTextSnippet(snippetIndex); + keyEvent.consume(); }); -// primaryStage.setTitle(this.chatcontroller.getChatPreferences().getChatState()); - -// scene.getStylesheets().add(getClass().getResource("application.css").toExternalForm()); MenuBar mainScreenMenuBar = initMenuBar(); -// HPane hbxNorthForStatusBar = new HBox(); flwpne_StatusBar = new FlowPane(); flwpne_StatusBar.getChildren().add(mainScreenMenuBar); @@ -6308,6 +6368,36 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL effectiveSelectedMember = tbl_chatMember.getSelectionModel().getSelectedItem(); } + /* + * Resolve variables only after the key or mouse event that edited the + * TextField has finished. This also supports variables entered manually + * instead of through a shortcut or snippet. + */ + String resolvedOutgoingText = + messageVariableResolver.resolveForSelectedStation( + txt_chatMessageUserInput.getText(), + effectiveSelectedMember + ); + + /* + * A variable may legitimately resolve to an empty string. In that case + * there is no message to send. + */ + if (resolvedOutgoingText == null || resolvedOutgoingText.isBlank()) { + txt_chatMessageUserInput.clear(); + return; + } + + /* + * Keep the existing protection against private messages to the local + * callsign, but perform the check at the controlled send boundary. + */ + if (isMessageAddressedToOwnCallsign(resolvedOutgoingText)) { + txt_chatMessageUserInput.clear(); + return; + } + + if (effectiveSelectedMember == null && chatcontroller != null && chatcontroller.getScoreService() != null) { @@ -6326,7 +6416,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * /cq message into another channel. */ ChatCategory sendMeInThisCat = resolveOutgoingChatCategory( - txt_chatMessageUserInput.getText(), + resolvedOutgoingText, effectiveSelectedMember ); @@ -6368,7 +6458,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL * - or the main category as fallback. */ sendMe.setChatCategory(sendMeInThisCat); - sendMe.setMessageText(txt_chatMessageUserInput.getText()); + sendMe.setMessageText(resolvedOutgoingText); /* * If operator sends "/cq CALL ..." then update the station metrics. @@ -6430,160 +6520,6 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL } } }); - txt_chatMessageUserInput.textProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, String oldValue, String newValue) { - - - if (txt_chatMessageUserInput.getText().contains("MYQRGSHORT")) { - System.out.println("MYQRGSHORT erkannt"); - - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("MYQRGSHORT", - chatcontroller.getChatPreferences().getMYQRGFirstCat().getValue().substring(0,7))); - } - - if (txt_chatMessageUserInput.getText().contains("MYQRG") && !txt_chatMessageUserInput.getText().contains("MYQRGSHORT")) { - System.out.println("MYQRG erkannt"); - - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("MYQRG", - chatcontroller.getChatPreferences().getMYQRGFirstCat().getValue())); - } - -// if (txt_chatMessageUserInput.getText().contains("SECONDQRGSHORT")) { -// System.out.println("SECONDQRGSHORT erkannt"); -// if (chatcontroller.getChatPreferences().getMYQRGSecondCat().getValue().length() >= 6 && chatcontroller.getChatPreferences().getMYQRGSecondCat().getValue().length() <= 8) { -// -// txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("SECONDQRGSHORT", -// chatcontroller.getChatPreferences().getMYQRGSecondCat().getValue().substring(0,7))); -// } else { -// txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("SECONDQRGSHORT", -// chatcontroller.getChatPreferences().getMYQRGSecondCat().getValue())); -// } -// } - - if (txt_chatMessageUserInput.getText().contains("SECONDQRG")) { - System.out.println("MYQRG2 erkannt"); - - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("SECONDQRG", - chatcontroller.getChatPreferences().getMYQRGSecondCat().getValue())); - } - - - if (txt_chatMessageUserInput.getText().contains("MYLOCATORSHORT")) { - System.out.println("MYLOCATORSHORT erkannt"); - -// txt_chatMessageUserInput.getText().replaceAll("MYQRG", chatcontroller.getChatPreferences().getMYQRG()); - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("MYLOCATORSHORT", - chatcontroller.getChatPreferences().getStn_loginLocatorMainCat().substring(0,4))); //JO51 instead of JO51JL - } - - if (txt_chatMessageUserInput.getText().contains("MYLOCATOR") && !txt_chatMessageUserInput.getText().contains("MYLOCATORSHORT")) { - System.out.println("MYLOCATOR erkannt"); - -// txt_chatMessageUserInput.getText().replaceAll("MYQRG", chatcontroller.getChatPreferences().getMYQRG()); - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("MYLOCATOR", - chatcontroller.getChatPreferences().getStn_loginLocatorMainCat())); - } - - - boolean noAirplaneHere = false; - - if (txt_chatMessageUserInput.getText().contains("FIRSTAP")) { - - if (selectedCallSignInfoStageChatMember != null) { - - if (selectedCallSignInfoStageChatMember.getCallSign() != chatcontroller.getChatPreferences().getStn_loginCallSign()) { - - if (selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo() != null) { - - if (selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes() != null) { - - if (selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().size() != 0) { - noAirplaneHere = false; - AirPlane airPlane = selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().get(0); - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("FIRSTAP", "a " + airPlane.getPotencialDescriptionAsWord() + - " in " + airPlane.getArrivingDurationMinutes() + " min")); - } else noAirplaneHere = true; - } else noAirplaneHere = true; - } - else noAirplaneHere = true; - } - } - - if (noAirplaneHere) { - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("FIRSTAP", - "no ap available")); - } - } - - if (txt_chatMessageUserInput.getText().contains("SECONDAP")) { - - if (selectedCallSignInfoStageChatMember != null) { - - if (selectedCallSignInfoStageChatMember.getCallSign() != chatcontroller.getChatPreferences().getStn_loginCallSign()) { - - if (selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo() != null) { - - if (selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes() != null) { - - if (selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().size() >= 2) { - System.out.println("RISINGAP : " + selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().size()); - AirPlane airPlane = selectedCallSignInfoStageChatMember.getAirPlaneReflectInfo().getRisingAirplanes().get(1); - - if (!airPlane.getPotencialDescriptionAsWord().isEmpty()) { - noAirplaneHere = false; - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("SECONDAP", "Next " + airPlane.getPotencialDescriptionAsWord() + - " in " + airPlane.getArrivingDurationMinutes() + " min")); - - } else noAirplaneHere = true; - - } else noAirplaneHere = true; - } else noAirplaneHere = true; - } - else noAirplaneHere = true; - } - } - - if (noAirplaneHere) { - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("SECONDAP", - "")); - } - } - - if (txt_chatMessageUserInput.getText().contains("QRZNAME")) { - - if (selectedCallSignInfoStageChatMember != null) { - - /** - * for any reason there is a (not critical) exception if i use String[] here, so I - * decided to use the whole name - */ -// try { -// -// String[] firstName = selectedCallSignInfoStageChatMember.getName().split(" "); -// String splitFirst =""; -// -// if (firstName.length > 1) { -// splitFirst = firstName[0]; -// } else splitFirst = selectedCallSignInfoStageChatMember.getName(); -// -// txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("QRZNAME", -// splitFirst)); //only first word of name field will be inserted -// } catch (Exception jfxBugExc) { -// -// } - - txt_chatMessageUserInput.setText(txt_chatMessageUserInput.getText().replaceAll("QRZNAME", - selectedCallSignInfoStageChatMember.getName())); - } - - } - - if (txt_chatMessageUserInput.getText().startsWith("/cq " + chatcontroller.getChatPreferences().getStn_loginCallSign())) { - txt_chatMessageUserInput.setText(" "); //prevent user sends a message to himself, that will cause errors - } - } - }); final Separator sepVert1 = new Separator(); @@ -8695,7 +8631,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL HBox hbxDemDirectoryActions = new HBox(8.0, btnUseDefaultDemDirectory, btnImportDemTiles); Label lbl_station_pstRotatorEnabled = - new Label("Enable PSTRotator interface (automatic QTF):"); + new Label("Enable PSTRotator (auto QTF):"); TextField txtFld_station_pstRotatorHost = new TextField( chatcontroller.getChatPreferences().getStn_pstRotatorHost() @@ -8872,7 +8808,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL }); grdPanelServerHostName.add( - new Label("ON4KST server [www.on4kst.org]:"), + new Label("ON4KST server [www.on4kst.info]:"), 0, 1 ); @@ -10265,78 +10201,121 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL /************************************************************************************* * shorts & snippets tab *************************************************************************************/ - GridPane grdPnlShorts = new GridPane(); grdPnlShorts.setPadding(new Insets(10, 10, 10, 10)); grdPnlShorts.setVgap(5); grdPnlShorts.setHgap(5); -// Label lblEnableTRXMsgbyUCX = new Label("Receive UCXLog network based UDP trx messages"); -// CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); + grdPnlShorts.add( + generateLabeledSeparator(100, "Shortcut buttons above the message field"), + 0, + 0, + 2, + 1 + ); - grdPnlShorts.add(generateLabeledSeparator(100, "Set the shortcut-Buttons (above Sendtext-field)"), 0, 0, 2, 1); + TableView tblVw_shortcuts = initShortcutTable(); + tblVw_shortcuts.setItems( + this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList() + ); - TableView tblVw_shortcuts = new TableView(); - tblVw_shortcuts = initShortcutTable(); - tblVw_shortcuts.setItems(this.chatcontroller.getChatPreferences().getLst_txtShortCutBtnList()); - - - Button btn_Short_addLine = new Button("Add new shorcut-button"); + Button btn_Short_addLine = new Button("Add shortcut"); btn_Short_addLine.setOnAction(new EventHandler() { @Override public void handle(ActionEvent event) { - - String newTextSnippet = "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; - chatcontroller.getChatPreferences().getLst_txtShortCutBtnList().add(0, newTextSnippet); + String newShortcut = + "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; + chatcontroller.getChatPreferences() + .getLst_txtShortCutBtnList() + .add(0, newShortcut); + tblVw_shortcuts.getSelectionModel().clearAndSelect(0); + tblVw_shortcuts.scrollTo(0); + tblVw_shortcuts.edit(0, tblVw_shortcuts.getColumns().get(0)); } }); - Button btn_Short_changePosPlus = new Button("move marked down"); - btn_Short_changePosPlus.setDisable(true); - Button btn_Short_changePosMinus = new Button("move marked up"); - btn_Short_changePosMinus.setDisable(true); + Button btn_Short_changePosPlus = new Button("Move selected down"); + btn_Short_changePosPlus.setOnAction(event -> { + if (moveSelectedTableEntry(tblVw_shortcuts, 1)) { + refreshShortcutButtons(); + } + }); + + Button btn_Short_changePosMinus = new Button("Move selected up"); + btn_Short_changePosMinus.setOnAction(event -> { + if (moveSelectedTableEntry(tblVw_shortcuts, -1)) { + refreshShortcutButtons(); + } + }); HBox hbxTxtShortBtnBox = new HBox(); - - grdPnlShorts.add(hbxTxtShortBtnBox, 0, 2, 2, 1); - hbxTxtShortBtnBox.getChildren().addAll(btn_Short_addLine, btn_Short_changePosPlus, btn_Short_changePosMinus); + hbxTxtShortBtnBox.getChildren().addAll( + btn_Short_addLine, + btn_Short_changePosPlus, + btn_Short_changePosMinus + ); grdPnlShorts.add(tblVw_shortcuts, 0, 1, 2, 1); + grdPnlShorts.add(hbxTxtShortBtnBox, 0, 2, 2, 1); - TableView tblVw_textsnippets = new TableView(); - tblVw_textsnippets = initTextSnippetsTable(); - tblVw_textsnippets.setItems(this.chatcontroller.getChatPreferences().getLst_txtSnipList()); + grdPnlShorts.add( + generateLabeledSeparator( + 100, + "Text snippets (the first 10 use Ctrl+1 through Ctrl+0)" + ), + 0, + 3, + 2, + 1 + ); - grdPnlShorts.add(tblVw_textsnippets, 0, 4, 2, 1); + TableView tblVw_textsnippets = initTextSnippetsTable(); + tblVw_textsnippets.setItems( + this.chatcontroller.getChatPreferences().getLst_txtSnipList() + ); Button btn_Snip_addLine = new Button("Add new snippet"); btn_Snip_addLine.setOnAction(new EventHandler() { @Override public void handle(ActionEvent event) { - - String newTextSnippet = "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; - chatcontroller.getChatPreferences().getLst_txtSnipList().add(0, newTextSnippet); + String newTextSnippet = + "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"; + chatcontroller.getChatPreferences() + .getLst_txtSnipList() + .add(0, newTextSnippet); + tblVw_textsnippets.getSelectionModel().clearAndSelect(0); + tblVw_textsnippets.scrollTo(0); + tblVw_textsnippets.edit(0, tblVw_textsnippets.getColumns().get(0)); } }); - Button btn_Snbip_changePosPlus = new Button("move marked down"); - btn_Snbip_changePosPlus.setDisable(true); - Button btn_Snip_changePosMinus = new Button("move marked up"); - btn_Snip_changePosMinus.setDisable(true); + Button btn_Snip_changePosPlus = new Button("Move selected down"); + btn_Snip_changePosPlus.setOnAction(event -> { + if (moveSelectedTableEntry(tblVw_textsnippets, 1)) { + refreshTextSnippetContextMenus(); + } + }); + + Button btn_Snip_changePosMinus = new Button("Move selected up"); + btn_Snip_changePosMinus.setOnAction(event -> { + if (moveSelectedTableEntry(tblVw_textsnippets, -1)) { + refreshTextSnippetContextMenus(); + } + }); HBox hbxTxtSnipBtnBox = new HBox(); + hbxTxtSnipBtnBox.getChildren().addAll( + btn_Snip_addLine, + btn_Snip_changePosPlus, + btn_Snip_changePosMinus + ); + grdPnlShorts.add(tblVw_textsnippets, 0, 4, 2, 1); grdPnlShorts.add(hbxTxtSnipBtnBox, 0, 5, 2, 1); - hbxTxtSnipBtnBox.getChildren().addAll(btn_Snip_addLine, btn_Snbip_changePosPlus, btn_Snip_changePosMinus); - -// grdPnlShorts.add(lblEnableTRXMsgbyUCX, 0, 1); -// grdPnlShorts.add(chkBxEnableTRXMsgbyUCX, 1, 1); - grdPnlShorts.add(generateLabeledSeparator(100, "Set the Text-snippets (First 10 are accessible by pressing + !)"), 0, - 3, 2, 1); VBox vbxShorts = new VBox(); vbxShorts.setPadding(new Insets(10, 10, 10, 10)); - vbxShorts.getChildren().addAll(grdPnlShorts); + vbxShorts.getChildren().add(grdPnlShorts); /************************************************************************************* * Beacons / CQ messages @@ -10347,161 +10326,138 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL grdPnlBeacon.setVgap(5); grdPnlBeacon.setHgap(5); -// Label lblEnableTRXMsgbyUCX = new Label("Receive UCXLog network based UDP trx messages"); -// CheckBox chkBxEnableTRXMsgbyUCX = new CheckBox(); + grdPnlBeacon.add( + generateLabeledSeparator(100, "CQ beacons for the public chat"), + 0, + 0, + 2, + 1 + ); - grdPnlBeacon.add(generateLabeledSeparator(100, "Set the Beacon (autointervalled CQ messages to public chat)"), - 0, 0, 2, 1); - grdPnlBeacon.add(new Label("[" + this.chatcontroller.getChatCategoryMain().getChatCategoryName(this.chatcontroller.getChatCategoryMain().getCategoryNumber())+ "] Enable CQ-like beacons:"), 0, 1); + ChatCategory mainBeaconCategory = chatcontroller.getChatCategoryMain(); + ChatCategory secondBeaconCategory = chatcontroller.getChatCategorySecondChat(); - grdPnlBeacon.add(new Label("[" + this.chatcontroller.getChatCategoryMain().getChatCategoryName(this.chatcontroller.getChatCategorySecondChat().getCategoryNumber())+ "] Enable CQ-like beacons:"), 0, 4); + String mainBeaconCategoryName = + mainBeaconCategory.getChatCategoryName(mainBeaconCategory.getCategoryNumber()); + String secondBeaconCategoryName = secondBeaconCategory == null + ? "Second chat category" + : secondBeaconCategory.getChatCategoryName(secondBeaconCategory.getCategoryNumber()); + + grdPnlBeacon.add( + new Label("[" + mainBeaconCategoryName + "] Enable CQ beacon:"), + 0, + 1 + ); CheckBox chkBxBeaconsEnabledMainCategory = new CheckBox(); - chkBxBeaconsEnabledMainCategory.setSelected(this.chatcontroller.getChatPreferences().isBcn_beaconsEnabledMainCat()); - - chkBxBeaconsEnabledMainCategory.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { - - chatcontroller.getChatPreferences().setBcn_beaconsEnabledMainCat(chkBxBeaconsEnabledMainCategory.isSelected()); - System.out.println("[Main.java, Info]: Beacons 1st category turned on: " + newValue); - } - }); + chkBxBeaconsEnabledMainCategory.setSelected( + chatcontroller.getChatPreferences().isBcn_beaconsEnabledMainCat() + ); + chkBxBeaconsEnabledMainCategory.selectedProperty().addListener( + (observable, oldValue, newValue) -> { + chatcontroller.getChatPreferences() + .setBcn_beaconsEnabledMainCat(newValue); + System.out.println("[Main.java, Info]: Main-category beacon enabled: " + + newValue); + } + ); grdPnlBeacon.add(chkBxBeaconsEnabledMainCategory, 1, 1); - CheckBox chkBxBeaconsEnabledSecondCategory = new CheckBox(); - chkBxBeaconsEnabledSecondCategory.setSelected(this.chatcontroller.getChatPreferences().isBcn_beaconsEnabledSecondCat()); + grdPnlBeacon.add( + new Label("Beacon message [max. " + + ChatController.MAX_BEACON_TEXT_LENGTH + + " characters]:"), + 0, + 2 + ); - chkBxBeaconsEnabledSecondCategory.selectedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue observable, Boolean oldValue, Boolean newValue) { + TextField txtFldBeaconText = new TextField( + chatcontroller.getChatPreferences().getBcn_beaconTextMainCat() + ); - chatcontroller.getChatPreferences().setBcn_beaconsEnabledSecondCat(chkBxBeaconsEnabledSecondCategory.isSelected()); - System.out.println("[Main.java, Info]: Beacons 2st category turned on: " + newValue); - } - }); - - grdPnlBeacon.add(chkBxBeaconsEnabledSecondCategory, 1, 4); - - - grdPnlBeacon.add(new Label("Beacon message [<100 Chars]:"), 0, 2); - - TextField txtFldBeaconText = new TextField(this.chatcontroller.getChatPreferences().getBcn_beaconTextMainCat()); + txtFldBeaconText.setPrefWidth(400); txtFldBeaconText.setFocusTraversable(false); grdPnlBeacon.add(txtFldBeaconText, 1, 2); - txtFldBeaconText.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { - // Do nothing until field loses focus, user will enter his frequency - } else { - System.out.println("[Main.java, Info]: Set the 1st cat beacon text to: " - + chatcontroller.getChatPreferences().getBcn_beaconTextMainCat()); - - if (txtFldBeaconText.getText().length() <= 120) { - chatcontroller.getChatPreferences().setBcn_beaconTextMainCat(txtFldBeaconText.getText()); - } else { - txtFldBeaconText.setText( - "That was too long, setting " + chatcontroller.getChatPreferences().getBcn_beaconTextMainCat()); + txtFldBeaconText.focusedProperty().addListener( + (observable, oldValue, focused) -> { + if (!focused) { + applyBeaconTextSetting(txtFldBeaconText, true); } -// MYQRGButton.setText(txt_ownqrg.getText()); } - } - }); + ); - grdPnlBeacon.add(new Label("Beacon-interval [minutes, >=5]:"), 0, 3); - - TextField txtFldBeaconInterval = new TextField(); - txtFldBeaconInterval.setText(this.chatcontroller.getChatPreferences().getBcn_beaconIntervalInMinutesMainCat() + ""); - - txtFldBeaconInterval.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { -// System.out.println("Textfield on focus"); - // Do nothing until field loses focus, user will enter his frequency - } else { - if (GuiUtils.isNumeric(txtFldBeaconInterval.getText())) { - -// chatcontroller.getChatPreferences().setMYQRG(txt_ownqrg.getText()); - chatcontroller.getChatPreferences() - .setBcn_beaconIntervalInMinutesMainCat((Integer.parseInt(txtFldBeaconInterval.getText()))); - System.out.println("[Main.java, Info]: reset the beacon-interval to: " - + txtFldBeaconInterval.getText()); - - } else { - txtFldBeaconInterval.setText(txtFldBeaconInterval.getText() + " is an invalid time value"); - } + grdPnlBeacon.add( + new Label("[" + secondBeaconCategoryName + "] Enable CQ beacon:"), + 0, + 3 + ); + CheckBox chkBxBeaconsEnabledSecondCategory = new CheckBox(); + chkBxBeaconsEnabledSecondCategory.setSelected( + chatcontroller.getChatPreferences().isBcn_beaconsEnabledSecondCat() + ); + chkBxBeaconsEnabledSecondCategory.selectedProperty().addListener( + (observable, oldValue, newValue) -> { + chatcontroller.getChatPreferences() + .setBcn_beaconsEnabledSecondCat(newValue); + System.out.println("[Main.java, Info]: Second-category beacon enabled: " + + newValue); } - } - }); + ); - grdPnlBeacon.add(txtFldBeaconInterval, 1, 3); + grdPnlBeacon.add(chkBxBeaconsEnabledSecondCategory, 1, 3); - grdPnlBeacon.add(new Label("Beacon message [<100 Chars]:"), 0, 5); + grdPnlBeacon.add( + new Label("Beacon message [max. " + + ChatController.MAX_BEACON_TEXT_LENGTH + + " characters]:"), + 0, + 4 + ); - TextField txtFldBeaconTextSecondCat = new TextField(this.chatcontroller.getChatPreferences().getBcn_beaconTextSecondCat()); + TextField txtFldBeaconTextSecondCat = new TextField( + chatcontroller.getChatPreferences().getBcn_beaconTextSecondCat() + ); txtFldBeaconTextSecondCat.setFocusTraversable(false); - grdPnlBeacon.add(txtFldBeaconTextSecondCat, 1, 5); - txtFldBeaconTextSecondCat.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { - // Do nothing until field loses focus, user will enter his frequency - } else { - System.out.println("[Main.java, Info]: Set the 2nd cat beacon text to: " - + chatcontroller.getChatPreferences().getBcn_beaconTextSecondCat()); - - if (txtFldBeaconTextSecondCat.getText().length() <= 120) { - chatcontroller.getChatPreferences().setBcn_beaconTextSecondCat((txtFldBeaconTextSecondCat.getText())); - } else { - txtFldBeaconTextSecondCat.setText( - "That was too long, setting " + chatcontroller.getChatPreferences().getBcn_beaconTextSecondCat()); + grdPnlBeacon.add(txtFldBeaconTextSecondCat, 1, 4); + txtFldBeaconTextSecondCat.focusedProperty().addListener( + (observable, oldValue, focused) -> { + if (!focused) { + applyBeaconTextSetting(txtFldBeaconTextSecondCat, false); } -// MYQRGButton.setText(txt_ownqrg.getText()); } - } - }); + ); + grdPnlBeacon.add( + new Label("Shared beacon interval [minutes, min. " + + ChatController.MIN_BEACON_INTERVAL_MINUTES + + "]:"), + 0, + 5 + ); - grdPnlBeacon.add(new Label("Beacon-interval [minutes, >=5]:"), 0, 6); - - TextField txtFldBeaconIntervalSecondCat = new TextField(); - txtFldBeaconIntervalSecondCat.setText(this.chatcontroller.getChatPreferences().getBcn_beaconIntervalInMinutesSecondCat() + ""); - - txtFldBeaconIntervalSecondCat.focusedProperty().addListener(new ChangeListener() { - @Override - public void changed(ObservableValue arg0, Boolean oldPropertyValue, - Boolean newPropertyValue) { - if (newPropertyValue) { - - } else { - if (GuiUtils.isNumeric(txtFldBeaconIntervalSecondCat.getText())) { - - chatcontroller.getChatPreferences() - .setBcn_beaconIntervalInMinutesSecondCat((Integer.parseInt(txtFldBeaconIntervalSecondCat.getText()))); - System.out.println("[Main.java, Info]: reset the beacon-interval (2nd) to: " - + txtFldBeaconIntervalSecondCat.getText()); - - } else { - txtFldBeaconIntervalSecondCat.setText(txtFldBeaconIntervalSecondCat.getText() + " is an invalid time value"); + TextField txtFldBeaconInterval = new TextField( + Integer.toString( + Math.max( + ChatController.MIN_BEACON_INTERVAL_MINUTES, + chatcontroller.getChatPreferences() + .getBcn_beaconIntervalInMinutesMainCat() + ) + ) + ); + txtFldBeaconInterval.focusedProperty().addListener( + (observable, oldValue, focused) -> { + if (!focused) { + applySharedBeaconInterval(txtFldBeaconInterval); } - } - } - }); - - grdPnlBeacon.add(txtFldBeaconIntervalSecondCat, 1, 6); - + ); + grdPnlBeacon.add(txtFldBeaconInterval, 1, 5); VBox vbxBeacon = new VBox(); vbxBeacon.setPadding(new Insets(10, 10, 10, 10)); - vbxBeacon.getChildren().addAll(grdPnlBeacon); + vbxBeacon.getChildren().add(grdPnlBeacon); /************************************************************************************* * Messagehandling ex Unworked station PM @@ -11432,6 +11388,113 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return null; } + + /** + * Maps Ctrl+1 through Ctrl+0 to snippet list indices 0 through 9. + * + * @param keyEvent keyboard event from the main scene + * @return snippet index or {@code -1} if the event is not a snippet shortcut + */ + private int resolveSnippetIndex(KeyEvent keyEvent) { + if (!keyEvent.isControlDown()) { + return -1; + } + + return switch (keyEvent.getCode()) { + case DIGIT1 -> 0; + case DIGIT2 -> 1; + case DIGIT3 -> 2; + case DIGIT4 -> 3; + case DIGIT5 -> 4; + case DIGIT6 -> 5; + case DIGIT7 -> 6; + case DIGIT8 -> 7; + case DIGIT9 -> 8; + case DIGIT0 -> 9; + default -> -1; + }; + } + + /** + * Resolves variables in a shortcut or snippet and appends the result to the + * message field. + * + *

The replacement is performed by the action that inserts the text. It is + * deliberately not performed by a text-property listener because changing a + * JavaFX TextField while the same key event is still being processed can leave + * invalid selection bounds behind.

+ * + * @param template shortcut or snippet text to append + */ + private void appendResolvedMessageText(String template) { + String resolvedText = messageVariableResolver.resolveForSelectedStation( + template, + getEffectiveSelectedChatMember() + ); + + if (resolvedText == null || resolvedText.isBlank()) { + return; + } + + String currentText = txt_chatMessageUserInput.getText(); + txt_chatMessageUserInput.setText( + (currentText == null ? "" : currentText) + resolvedText + ); + txt_chatMessageUserInput.requestFocus(); + txt_chatMessageUserInput.selectEnd(); + } + + /** + * Checks whether an outgoing private message targets the local callsign. + * + * @param messageText outgoing message text + * @return {@code true} if the /cq target is the local station + */ + private boolean isMessageAddressedToOwnCallsign(String messageText) { + String targetCallsign = extractCqTargetCallsign(messageText); + + if (targetCallsign == null + || chatcontroller == null + || chatcontroller.getChatPreferences() == null) { + return false; + } + + String ownCallsign = chatcontroller.getChatPreferences().getStn_loginCallSign(); + return ownCallsign != null && targetCallsign.equalsIgnoreCase(ownCallsign.trim()); + } + + /** + * Inserts one configured snippet as a private message to the selected station. + * + *

Missing selections and unassigned snippet positions are valid operator + * states. They are checked explicitly and no longer handled through a generic + * exception.

+ * + * @param snippetIndex zero-based index in the configured snippet list + */ + private void insertTextSnippet(int snippetIndex) { + ChatMember selectedStation = getEffectiveSelectedChatMember(); + ObservableList snippets = chatcontroller.getChatPreferences().getLst_txtSnipList(); + + if (selectedStation == null || selectedStation.getCallSign() == null) { + System.out.println("[Main.java, Info]: Text snippet ignored because no station is selected."); + return; + } + + if (snippetIndex < 0 || snippetIndex >= snippets.size()) { + System.out.println("[Main.java, Info]: No text snippet is configured for index " + snippetIndex + "."); + return; + } + + String preparedMessage = "/cq " + selectedStation.getCallSign() + " " + snippets.get(snippetIndex); + txt_chatMessageUserInput.setText( + messageVariableResolver.resolveForSelectedStation(preparedMessage, selectedStation) + ); + txt_chatMessageUserInput.requestFocus(); + txt_chatMessageUserInput.selectEnd(); + } + + /** * Resolves the chat category for an outgoing operator message. * diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 1aa283a..4fc4c9c 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -10,6 +10,8 @@ module praktiKST { requires java.net.http; requires java.desktop; requires jdk.crypto.ec; + requires org.junit.jupiter.api; + requires org.mockito; exports kst4contest.controller.interfaces; exports kst4contest.controller; exports kst4contest.locatorUtils;