From 5892ce09d227aae30c3535049961bbb7aa2ef504 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 7 Sep 2026 07:54:32 +0000 Subject: [PATCH] Manage operator profiles from the settings window Adds a "Profiles" tab, appended last so no established tab position shifts, offering create, duplicate, rename, delete, a switch between shared and own worked stations, and activation of another profile. Duplicating copies the whole configuration except callsign and password. The antenna, locator, layout and integration settings are exactly the work nobody wants to enter twice, while the credentials belong to one operator only. The root profile can neither be deleted nor moved off the common station database, because its files are the installation itself. The login callsign and its raw form now default to empty instead of a real callsign. The XML reader treats an empty element as "not set" and falls back to the field default, so without this change a profile created without credentials would come up carrying the callsign compiled into the defaults - and an operator could transmit under someone else's call. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm --- .../OperatorProfileManagementService.java | 312 +++++++++++ .../kst4contest/model/ChatPreferences.java | 10 +- .../view/Kst4ContestApplication.java | 28 +- .../view/OperatorProfileSettingsPane.java | 488 ++++++++++++++++++ .../OperatorProfileManagementServiceTest.java | 177 +++++++ 5 files changed, 1010 insertions(+), 5 deletions(-) create mode 100644 src/main/java/kst4contest/controller/OperatorProfileManagementService.java create mode 100644 src/main/java/kst4contest/view/OperatorProfileSettingsPane.java create mode 100644 src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java diff --git a/src/main/java/kst4contest/controller/OperatorProfileManagementService.java b/src/main/java/kst4contest/controller/OperatorProfileManagementService.java new file mode 100644 index 00000000..06a7a8c5 --- /dev/null +++ b/src/main/java/kst4contest/controller/OperatorProfileManagementService.java @@ -0,0 +1,312 @@ +package kst4contest.controller; + +import kst4contest.ApplicationConstants; +import kst4contest.model.ChatPreferences; +import kst4contest.model.OperatorProfile; +import kst4contest.utils.ApplicationFileUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Creates, renames, duplicates and removes operator profiles. + * + *

Kept free of user interface code so the behaviour can be tested without a JavaFX + * runtime. All methods work on the registry and on the files below the application + * directory.

+ */ +public class OperatorProfileManagementService { + + private static final Logger LOGGER = + Logger.getLogger(OperatorProfileManagementService.class.getName()); + + private final OperatorProfileStore profileStore; + + public OperatorProfileManagementService() { + this(new OperatorProfileStore()); + } + + public OperatorProfileManagementService(final OperatorProfileStore profileStore) { + this.profileStore = profileStore; + } + + /** + * Lists all profiles, including the implicit root profile of a plain installation. + * + * @return the known profiles, never empty + */ + public List listProfiles() { + + List knownProfiles = profileStore.loadProfiles(); + + if (knownProfiles.isEmpty()) { + knownProfiles = new ArrayList<>(); + knownProfiles.add(profileStore.buildImplicitRootProfile()); + } + + return knownProfiles; + } + + /** + * Creates a new operator profile with its own preferences file. + * + *

Creating the first additional profile is also the moment the registry appears: + * the root profile is written alongside, so both are selectable afterwards.

+ * + * @param displayName name entered by the operator + * @param sharedWorkedDatabase true to use the common station worked database + * @return the created profile, or null when it could not be stored + */ + public OperatorProfile createProfile(final String displayName, final boolean sharedWorkedDatabase) { + + List knownProfiles = listProfiles(); + Set takenProfileIds = new LinkedHashSet<>(); + + for (OperatorProfile existingProfile : knownProfiles) { + takenProfileIds.add(existingProfile.getProfileId()); + } + + OperatorProfile createdProfile = new OperatorProfile( + OperatorProfilePaths.toProfileId(displayName, takenProfileIds), + displayName == null || displayName.isBlank() ? "New profile" : displayName.trim(), + false, + sharedWorkedDatabase); + + knownProfiles.add(createdProfile); + + if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) { + return null; + } + + createPreferencesFile(createdProfile, null); + + return createdProfile; + } + + /** + * Creates a copy of an existing profile. + * + *

The preferences are taken over completely except for the login credentials: + * callsign and password are cleared on purpose, because a duplicate is meant for + * another operator. Antenna, locator, layout and integration settings are exactly + * what the operator does not want to enter twice.

+ * + *

The worked-station database is never copied.

+ * + * @param sourceProfile profile to copy + * @param displayName name of the new profile + * @return the created profile, or null when it could not be stored + */ + public OperatorProfile duplicateProfile(final OperatorProfile sourceProfile, final String displayName) { + + if (sourceProfile == null) { + return null; + } + + List knownProfiles = listProfiles(); + Set takenProfileIds = new LinkedHashSet<>(); + + for (OperatorProfile existingProfile : knownProfiles) { + takenProfileIds.add(existingProfile.getProfileId()); + } + + OperatorProfile createdProfile = new OperatorProfile( + OperatorProfilePaths.toProfileId(displayName, takenProfileIds), + displayName == null || displayName.isBlank() ? "Copy" : displayName.trim(), + false, + sourceProfile.isSharedWorkedDatabase()); + + knownProfiles.add(createdProfile); + + if (!profileStore.saveProfiles(knownProfiles, createdProfile.getProfileId())) { + return null; + } + + createPreferencesFile(createdProfile, sourceProfile); + + return createdProfile; + } + + /** + * Changes the visible name of a profile. The identifier and all paths stay as they are. + * + * @param profile profile to rename + * @param newDisplayName new name + * @return true if the registry was updated + */ + public boolean renameProfile(final OperatorProfile profile, final String newDisplayName) { + + if (profile == null || newDisplayName == null || newDisplayName.isBlank()) { + return false; + } + + List knownProfiles = listProfiles(); + + for (OperatorProfile currentProfile : knownProfiles) { + if (currentProfile.getProfileId().equals(profile.getProfileId())) { + currentProfile.setDisplayName(newDisplayName.trim()); + } + } + + return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null)); + } + + /** + * Switches a profile between the common station database and its own one. + * + * @param profile profile to change + * @param sharedWorkedDatabase true to use the common station worked database + * @return true if the registry was updated + */ + public boolean setSharedWorkedDatabase(final OperatorProfile profile, final boolean sharedWorkedDatabase) { + + if (profile == null || profile.isRootProfile()) { + return false; + } + + List knownProfiles = listProfiles(); + + for (OperatorProfile currentProfile : knownProfiles) { + if (currentProfile.getProfileId().equals(profile.getProfileId())) { + currentProfile.setSharedWorkedDatabase(sharedWorkedDatabase); + } + } + + return profileStore.saveProfiles(knownProfiles, profileStore.loadLastUsedProfileId().orElse(null)); + } + + /** + * Removes a profile and its directory. + * + *

The root profile can never be removed, because its files are the installation + * itself. A profile using the common station database keeps that database untouched; + * only its own directory is deleted.

+ * + * @param profile profile to remove + * @return true if the profile was removed + */ + public boolean deleteProfile(final OperatorProfile profile) { + + if (profile == null || profile.isRootProfile()) { + return false; + } + + List remainingProfiles = new ArrayList<>(); + + for (OperatorProfile currentProfile : listProfiles()) { + if (!currentProfile.getProfileId().equals(profile.getProfileId())) { + remainingProfiles.add(currentProfile); + } + } + + if (!profileStore.saveProfiles(remainingProfiles, + profileStore.loadLastUsedProfileId().orElse(null))) { + return false; + } + + deleteProfileDirectory(profile); + + return true; + } + + /** + * Returns the absolute directory of a profile. + * + * @param profile profile to resolve + * @return absolute profile directory + */ + public String getProfileDirectory(final OperatorProfile profile) { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, + OperatorProfilePaths.profileRelativeDirectory(profile)); + } + + /** + * Creates the preferences file of a new profile. + * + *

The file is either seeded from the bundled template or copied from the source + * profile. In both cases the login credentials are cleared, so a new profile never + * carries another operator's callsign or password.

+ * + * @param createdProfile profile that needs a preferences file + * @param sourceProfile profile to copy the preferences from, or null for the template + */ + private void createPreferencesFile(final OperatorProfile createdProfile, + final OperatorProfile sourceProfile) { + + String createdRelativeFileName = OperatorProfilePaths.preferencesRelativeFileName(createdProfile); + + if (sourceProfile != null) { + copyPreferencesFile( + OperatorProfilePaths.preferencesRelativeFileName(sourceProfile), + createdRelativeFileName); + } + + // Seeds from the bundled template when nothing was copied, and always resolves + // the preferences of the new profile. + ChatPreferences createdPreferences = new ChatPreferences(createdRelativeFileName); + createdPreferences.readPreferencesFromXmlFile(); + + createdPreferences.setStn_loginCallSign(""); + createdPreferences.setStn_loginPassword(""); + + createdPreferences.writePreferencesToXmlFile(); + } + + private void copyPreferencesFile(final String sourceRelativeFileName, + final String targetRelativeFileName) { + + Path sourcePath = Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, sourceRelativeFileName)); + Path targetPath = Path.of(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, targetRelativeFileName)); + + if (!Files.isRegularFile(sourcePath)) { + return; + } + + try { + Path targetDirectory = targetPath.getParent(); + + if (targetDirectory != null) { + Files.createDirectories(targetDirectory); + } + + Files.copy(sourcePath, targetPath); + } catch (IOException e) { + LOGGER.log(Level.WARNING, + "Could not copy the preferences of the source profile, using the defaults instead", e); + } + } + + private void deleteProfileDirectory(final OperatorProfile profile) { + + Path profileDirectory = Path.of(getProfileDirectory(profile)); + + if (!Files.isDirectory(profileDirectory)) { + return; + } + + try (Stream containedPaths = Files.walk(profileDirectory)) { + List deepestFirst = containedPaths + .sorted(Comparator.reverseOrder()) + .toList(); + + for (Path currentPath : deepestFirst) { + Files.deleteIfExists(currentPath); + } + } catch (IOException e) { + LOGGER.log(Level.WARNING, + "Could not remove the directory of the deleted operator profile", e); + } + } +} diff --git a/src/main/java/kst4contest/model/ChatPreferences.java b/src/main/java/kst4contest/model/ChatPreferences.java index f07015ef..a47a2972 100644 --- a/src/main/java/kst4contest/model/ChatPreferences.java +++ b/src/main/java/kst4contest/model/ChatPreferences.java @@ -187,8 +187,14 @@ public class ChatPreferences { int stn_pstRotatorPort = 12000; boolean stn_loginAFKState = false; //always start as here - String stn_loginCallSign = "do5amf"; - String stn_loginCallSignRaw = "do5amf"; //for example: do5amf instead of logincallsign do5amf-2 + /* + * The login credentials default to empty on purpose. A missing or empty value in + * preferences.xml means "not configured yet", and falling back to a real callsign + * would let an operator transmit under someone else's call. This matters for every + * additional operator profile, whose preferences are created without credentials. + */ + String stn_loginCallSign = ""; + String stn_loginCallSignRaw = ""; //for example: do5amf instead of logincallsign do5amf-2 String stn_loginPassword = ""; String stn_loginNameMainCat = "KST4Contest"; String stn_loginNameSecondCat = "KST4ContestSHF"; diff --git a/src/main/java/kst4contest/view/Kst4ContestApplication.java b/src/main/java/kst4contest/view/Kst4ContestApplication.java index c9a67f04..dfdbf842 100644 --- a/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -73,6 +73,7 @@ import kst4contest.utils.ApplicationFileUtils; import kst4contest.view.map.StationMapBridge; import kst4contest.controller.ActiveOperatorProfile; import kst4contest.controller.OperatorProfileStore; +import kst4contest.controller.OperatorProfileManagementService; import kst4contest.controller.OperatorProfilePaths; import kst4contest.model.OperatorProfile; import kst4contest.model.OperatorProfileSelection; @@ -6345,11 +6346,24 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL return; } - if (!confirmOperatorProfileSwitch(chosenProfile.get())) { + requestOperatorProfileSwitch(chosenProfile.get()); + } + + /** + * Confirms and performs a switch to another operator profile. + * + *

Shared by the File menu and the profile settings tab, so both ask the same + * question before giving up the running session.

+ * + * @param targetProfile profile to activate + */ + private void requestOperatorProfileSwitch(OperatorProfile targetProfile) { + + if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) { return; } - ApplicationRuntimeLauncher.switchProfile(chosenProfile.get()); + ApplicationRuntimeLauncher.switchProfile(targetProfile); } /** @@ -12028,6 +12042,14 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL Tab tbInternalDB = new Tab("Workedstn database", vbxInternalDB); Tab tbGui = new Tab("GUI", vbxGuiOptions); + /* + * Appended last on purpose so no established tab position shifts. Contest + * operators navigate these tabs by muscle memory. + */ + Tab tbProfiles = new Tab("Profiles", new OperatorProfileSettingsPane( + new OperatorProfileManagementService(), + this::requestOperatorProfileSwitch)); + /** * Automatic update of tab contents out of the database @@ -12043,7 +12065,7 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL tabPaneOptions.getTabs().addAll(tbStationSettings, tbLogSynchSet, tbTRXSynchSet, tbAirScoutSettings, tbNotify, - tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui); + tbShorts, tbBeacon, tbMsgHandling, tbInternalDB, tbGui, tbProfiles); optionsPanel.setLeft(tabPaneOptions); diff --git a/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java b/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java new file mode 100644 index 00000000..444558bf --- /dev/null +++ b/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java @@ -0,0 +1,488 @@ +package kst4contest.view; + +import kst4contest.controller.ActiveOperatorProfile; +import kst4contest.controller.OperatorProfileManagementService; +import kst4contest.controller.OperatorProfilePaths; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; + +import javafx.beans.property.SimpleStringProperty; +import javafx.geometry.Insets; +import javafx.scene.control.Alert; +import javafx.scene.control.Alert.AlertType; +import javafx.scene.control.Button; +import javafx.scene.control.ButtonBar; +import javafx.scene.control.ButtonType; +import javafx.scene.control.Dialog; +import javafx.scene.control.Label; +import javafx.scene.control.RadioButton; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.TextInputDialog; +import javafx.scene.control.ToggleGroup; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.util.Pair; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +/** + * Settings tab that manages the operator profiles. + * + *

Every profile keeps its own settings and window layout. Whether it also keeps its + * own worked stations is chosen per profile, because a multi operator contest station + * shares one log while two operators on a private computer usually do not.

+ */ +public class OperatorProfileSettingsPane extends VBox { + + private static final DateTimeFormatter LAST_USED_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault()); + + private final OperatorProfileManagementService managementService; + private final Consumer profileActivationRequest; + + private final TableView profileTable = new TableView<>(); + private final Label activeProfileLabel = new Label(); + private final Label preferencesPathLabel = new Label(); + private final Label workedDatabasePathLabel = new Label(); + + public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService, + final Consumer profileActivationRequest) { + + this.managementService = managementService; + this.profileActivationRequest = profileActivationRequest; + + setSpacing(10); + setPadding(new Insets(15)); + + getChildren().addAll( + buildActiveProfileHeader(), + buildProfileTable(), + buildButtonRows(), + buildExplanationLabel()); + + refreshActiveProfileHeader(); + refreshProfileTable(); + } + + private GridPane buildActiveProfileHeader() { + + GridPane headerGrid = new GridPane(); + headerGrid.setHgap(10); + headerGrid.setVgap(4); + + headerGrid.add(new Label("Active profile:"), 0, 0); + headerGrid.add(activeProfileLabel, 1, 0); + headerGrid.add(new Label("Settings file:"), 0, 1); + headerGrid.add(preferencesPathLabel, 1, 1); + headerGrid.add(new Label("Worked stations:"), 0, 2); + headerGrid.add(workedDatabasePathLabel, 1, 2); + + return headerGrid; + } + + private TableView buildProfileTable() { + + TableColumn nameColumn = new TableColumn<>("Profile"); + nameColumn.setCellValueFactory(cellData -> + new SimpleStringProperty(cellData.getValue().getDisplayName())); + nameColumn.setPrefWidth(200); + + TableColumn workedDataColumn = new TableColumn<>("Worked stations"); + workedDataColumn.setCellValueFactory(cellData -> + new SimpleStringProperty(describeWorkedDataMode(cellData.getValue()))); + workedDataColumn.setPrefWidth(200); + + TableColumn lastUsedColumn = new TableColumn<>("Last used"); + lastUsedColumn.setCellValueFactory(cellData -> + new SimpleStringProperty(describeLastUsed(cellData.getValue()))); + lastUsedColumn.setPrefWidth(140); + + profileTable.getColumns().add(nameColumn); + profileTable.getColumns().add(workedDataColumn); + profileTable.getColumns().add(lastUsedColumn); + profileTable.setPlaceholder(new Label("No operator profile configured.")); + + VBox.setVgrow(profileTable, Priority.ALWAYS); + + return profileTable; + } + + private VBox buildButtonRows() { + + Button newProfileButton = new Button("New profile..."); + newProfileButton.setOnAction(event -> createProfile()); + + Button duplicateProfileButton = new Button("Duplicate..."); + duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile()); + + Button renameProfileButton = new Button("Rename..."); + renameProfileButton.setOnAction(event -> renameSelectedProfile()); + + Button deleteProfileButton = new Button("Delete..."); + deleteProfileButton.setOnAction(event -> deleteSelectedProfile()); + + Button changeWorkedDataButton = new Button("Change worked stations..."); + changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile()); + + Button switchProfileButton = new Button("Switch to selected profile..."); + switchProfileButton.setOnAction(event -> activateSelectedProfile()); + + HBox managementRow = new HBox(8, + newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton); + HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton); + + return new VBox(8, managementRow, activationRow); + } + + private Label buildExplanationLabel() { + + Label explanation = new Label( + "Each profile has its own settings and window layout. A profile can either share the " + + "common station worked stations, which is what a multi operator station wants, " + + "or keep its own. Duplicating a profile copies everything except callsign and " + + "password, and never copies worked stations."); + explanation.setWrapText(true); + + return explanation; + } + + private void refreshActiveProfileHeader() { + + OperatorProfileSelection activeProfile = ActiveOperatorProfile.get(); + + if (activeProfile == null) { + activeProfileLabel.setText("unknown"); + return; + } + + activeProfileLabel.setText(activeProfile.getProfile().getDisplayName()); + preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath()); + workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath()); + } + + private void refreshProfileTable() { + + List knownProfiles = managementService.listProfiles(); + OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem(); + + profileTable.getItems().setAll(knownProfiles); + + if (previouslySelected != null && knownProfiles.contains(previouslySelected)) { + profileTable.getSelectionModel().select(previouslySelected); + } else if (!knownProfiles.isEmpty()) { + profileTable.getSelectionModel().select(0); + } + } + + private void createProfile() { + + Optional> enteredProfile = + showProfileCreationDialog("New operator profile", ""); + + if (enteredProfile.isEmpty()) { + return; + } + + OperatorProfile createdProfile = managementService.createProfile( + enteredProfile.get().getKey(), enteredProfile.get().getValue()); + + if (createdProfile == null) { + showError("The profile could not be created. The profile registry could not be written."); + return; + } + + refreshProfileTable(); + profileTable.getSelectionModel().select(createdProfile); + + showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without " + + "callsign and password. Enter them on the Station tab after switching to it."); + } + + private void duplicateSelectedProfile() { + + OperatorProfile selectedProfile = requireSelectedProfile(); + + if (selectedProfile == null) { + return; + } + + TextInputDialog nameDialog = + new TextInputDialog("Copy of " + selectedProfile.getDisplayName()); + nameDialog.setTitle("Duplicate operator profile"); + nameDialog.setHeaderText("Name of the new profile"); + nameDialog.setContentText( + "Everything is copied except callsign and password. Worked stations are never copied."); + + Optional enteredName = nameDialog.showAndWait(); + + if (enteredName.isEmpty() || enteredName.get().isBlank()) { + return; + } + + OperatorProfile duplicatedProfile = + managementService.duplicateProfile(selectedProfile, enteredName.get()); + + if (duplicatedProfile == null) { + showError("The profile could not be duplicated."); + return; + } + + refreshProfileTable(); + profileTable.getSelectionModel().select(duplicatedProfile); + } + + private void renameSelectedProfile() { + + OperatorProfile selectedProfile = requireSelectedProfile(); + + if (selectedProfile == null) { + return; + } + + TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName()); + nameDialog.setTitle("Rename operator profile"); + nameDialog.setHeaderText("New name of the profile"); + nameDialog.setContentText("Files and folders of the profile are not touched."); + + Optional enteredName = nameDialog.showAndWait(); + + if (enteredName.isEmpty() || enteredName.get().isBlank()) { + return; + } + + managementService.renameProfile(selectedProfile, enteredName.get()); + refreshProfileTable(); + refreshActiveProfileHeader(); + } + + private void deleteSelectedProfile() { + + OperatorProfile selectedProfile = requireSelectedProfile(); + + if (selectedProfile == null) { + return; + } + + if (selectedProfile.isRootProfile()) { + showError("The default profile uses the files of the installation itself " + + "and cannot be deleted."); + return; + } + + if (isActiveProfile(selectedProfile)) { + showError("The profile currently in use cannot be deleted. Switch to another " + + "profile first."); + return; + } + + Alert confirmation = new Alert(AlertType.CONFIRMATION); + confirmation.setTitle("Delete operator profile"); + confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?"); + confirmation.setContentText( + "The following folder is removed permanently:\n" + + managementService.getProfileDirectory(selectedProfile) + + "\n\n" + + (selectedProfile.isSharedWorkedDatabase() + ? "The common station worked stations are not touched." + : "The worked stations of this profile are deleted as well.")); + + ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE); + ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + confirmation.getButtonTypes().setAll(deleteButton, cancelButton); + + if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) { + return; + } + + if (!managementService.deleteProfile(selectedProfile)) { + showError("The profile could not be deleted."); + } + + refreshProfileTable(); + } + + private void changeWorkedDataModeOfSelectedProfile() { + + OperatorProfile selectedProfile = requireSelectedProfile(); + + if (selectedProfile == null) { + return; + } + + if (selectedProfile.isRootProfile()) { + showError("The default profile always uses the common station worked stations, " + + "because that database is the one of the installation itself."); + return; + } + + Optional chosenMode = showWorkedDataModeDialog(selectedProfile); + + if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) { + return; + } + + managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get()); + refreshProfileTable(); + + if (isActiveProfile(selectedProfile)) { + showInformation("The change takes effect after switching to this profile again."); + } + } + + private void activateSelectedProfile() { + + OperatorProfile selectedProfile = requireSelectedProfile(); + + if (selectedProfile == null) { + return; + } + + if (isActiveProfile(selectedProfile)) { + showInformation("This profile is already active."); + return; + } + + profileActivationRequest.accept(selectedProfile); + } + + private Optional> showProfileCreationDialog(final String title, + final String initialName) { + + Dialog> creationDialog = new Dialog<>(); + creationDialog.setTitle(title); + creationDialog.setHeaderText("Name and worked stations of the new profile"); + + ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE); + ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton); + + TextField nameField = new TextField(initialName); + nameField.setPromptText("for example DN9APW"); + + ToggleGroup workedDataGroup = new ToggleGroup(); + + RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile"); + ownDatabaseOption.setToggleGroup(workedDataGroup); + ownDatabaseOption.setSelected(true); + + RadioButton sharedDatabaseOption = + new RadioButton("Share the common station worked stations (multi operator station)"); + sharedDatabaseOption.setToggleGroup(workedDataGroup); + + VBox dialogContent = new VBox(8, + new Label("Profile name"), + nameField, + new Label("Worked stations"), + ownDatabaseOption, + sharedDatabaseOption); + dialogContent.setPadding(new Insets(10)); + + creationDialog.getDialogPane().setContent(dialogContent); + + creationDialog.setResultConverter(pressedButton -> { + if (pressedButton != createButton || nameField.getText().isBlank()) { + return null; + } + + return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected()); + }); + + return creationDialog.showAndWait(); + } + + private Optional showWorkedDataModeDialog(final OperatorProfile profile) { + + Dialog modeDialog = new Dialog<>(); + modeDialog.setTitle("Worked stations"); + modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\""); + + ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE); + ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE); + modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton); + + ToggleGroup workedDataGroup = new ToggleGroup(); + + RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile"); + ownDatabaseOption.setToggleGroup(workedDataGroup); + + RadioButton sharedDatabaseOption = + new RadioButton("Share the common station worked stations (multi operator station)"); + sharedDatabaseOption.setToggleGroup(workedDataGroup); + + sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase()); + ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase()); + + Label pathHint = new Label("Switching does not move any data. Worked stations already " + + "collected under the other setting stay where they are."); + pathHint.setWrapText(true); + + VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint); + dialogContent.setPadding(new Insets(10)); + + modeDialog.getDialogPane().setContent(dialogContent); + modeDialog.setResultConverter(pressedButton -> + pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null); + + return modeDialog.showAndWait(); + } + + private OperatorProfile requireSelectedProfile() { + + OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem(); + + if (selectedProfile == null) { + showInformation("Select a profile in the table first."); + } + + return selectedProfile; + } + + private static boolean isActiveProfile(final OperatorProfile profile) { + + OperatorProfileSelection activeProfile = ActiveOperatorProfile.get(); + + return activeProfile != null + && activeProfile.getProfile().getProfileId().equals(profile.getProfileId()); + } + + private static String describeWorkedDataMode(final OperatorProfile profile) { + + if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) { + return "common station database"; + } + + return "own database"; + } + + private static String describeLastUsed(final OperatorProfile profile) { + + if (profile.getLastUsedEpochMs() <= 0L) { + return ""; + } + + return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs())); + } + + private static void showInformation(final String message) { + Alert information = new Alert(AlertType.INFORMATION); + information.setTitle("Operator profiles"); + information.setContentText(message); + information.showAndWait(); + } + + private static void showError(final String message) { + Alert error = new Alert(AlertType.ERROR); + error.setTitle("Operator profiles"); + error.setContentText(message); + error.showAndWait(); + } +} diff --git a/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java b/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java new file mode 100644 index 00000000..9a37e637 --- /dev/null +++ b/src/test/java/kst4contest/controller/OperatorProfileManagementServiceTest.java @@ -0,0 +1,177 @@ +package kst4contest.controller; + +import kst4contest.model.ChatPreferences; +import kst4contest.model.OperatorProfile; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfileManagementServiceTest { + + private static final String USER_HOME_PROPERTY = "user.home"; + + @TempDir + Path temporaryHomeDirectory; + + private String originalUserHome; + private OperatorProfileManagementService managementService; + + @BeforeEach + void redirectUserHomeToTemporaryDirectory() { + originalUserHome = System.getProperty(USER_HOME_PROPERTY); + System.setProperty(USER_HOME_PROPERTY, temporaryHomeDirectory.toString()); + managementService = new OperatorProfileManagementService(); + } + + @AfterEach + void restoreUserHome() { + if (originalUserHome == null) { + System.clearProperty(USER_HOME_PROPERTY); + } else { + System.setProperty(USER_HOME_PROPERTY, originalUserHome); + } + } + + @Test + void aPlainInstallationReportsExactlyOneImplicitRootProfile() { + + List knownProfiles = managementService.listProfiles(); + + assertEquals(1, knownProfiles.size()); + assertTrue(knownProfiles.get(0).isRootProfile()); + assertFalse(Files.exists(applicationFile("profiles.xml")), + "Merely listing profiles must not create a registry"); + } + + @Test + void creatingTheSecondProfileMaterialisesTheRegistryIncludingTheRootProfile() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + assertNotNull(createdProfile); + assertEquals("DN9APW", createdProfile.getProfileId()); + assertTrue(Files.exists(applicationFile("profiles.xml"))); + + List knownProfiles = managementService.listProfiles(); + + assertEquals(2, knownProfiles.size()); + assertTrue(knownProfiles.get(0).isRootProfile()); + assertEquals("DN9APW", knownProfiles.get(1).getProfileId()); + + assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml"))); + + // The historic files must stay exactly where an older release expects them. + assertFalse(Files.exists(applicationFile("profiles/default"))); + } + + @Test + void aNewProfileStartsWithoutLoginCredentials() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + ChatPreferences createdPreferences = + preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(createdProfile)); + + assertEquals("", createdPreferences.getStn_loginCallSign()); + assertEquals("", createdPreferences.getStn_loginPassword()); + } + + @Test + void duplicatingKeepsTheStationSetupButClearsCallsignAndPassword() { + + OperatorProfile sourceProfile = managementService.createProfile("Source", false); + + ChatPreferences sourcePreferences = + preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(sourceProfile)); + sourcePreferences.setStn_loginCallSign("DM5M"); + sourcePreferences.setStn_loginPassword("secret"); + sourcePreferences.setStn_loginLocatorMainCat("JO51IJ"); + sourcePreferences.setStn_antennaBeamWidthDeg(17.5); + assertTrue(sourcePreferences.writePreferencesToXmlFile()); + + OperatorProfile duplicatedProfile = + managementService.duplicateProfile(sourceProfile, "Copy of source"); + + assertNotNull(duplicatedProfile); + + ChatPreferences duplicatedPreferences = + preferencesAt(OperatorProfilePaths.preferencesRelativeFileName(duplicatedProfile)); + + // The work worth keeping. + assertEquals("JO51IJ", duplicatedPreferences.getStn_loginLocatorMainCat()); + assertEquals(17.5, duplicatedPreferences.getStn_antennaBeamWidthDeg()); + + // The identity that must not be inherited. + assertEquals("", duplicatedPreferences.getStn_loginCallSign()); + assertEquals("", duplicatedPreferences.getStn_loginPassword()); + } + + @Test + void switchingBetweenSharedAndOwnWorkedDataChangesOnlyTheDatabasePath() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + assertEquals("profiles/DN9APW/praktiKST.db", + OperatorProfilePaths.workedDatabaseRelativeFileName(createdProfile)); + + assertTrue(managementService.setSharedWorkedDatabase(createdProfile, true)); + + OperatorProfile reloadedProfile = managementService.listProfiles().get(1); + + assertTrue(reloadedProfile.isSharedWorkedDatabase()); + assertEquals("praktiKST.db", + OperatorProfilePaths.workedDatabaseRelativeFileName(reloadedProfile)); + assertEquals("profiles/DN9APW/preferences.xml", + OperatorProfilePaths.preferencesRelativeFileName(reloadedProfile)); + } + + @Test + void deletingRemovesTheProfileDirectoryButNeverTheRootProfile() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + assertTrue(Files.exists(applicationFile("profiles/DN9APW/preferences.xml"))); + + OperatorProfile rootProfile = managementService.listProfiles().get(0); + assertFalse(managementService.deleteProfile(rootProfile), + "The root profile is the installation itself and must not be removable"); + + assertTrue(managementService.deleteProfile(createdProfile)); + assertFalse(Files.exists(applicationFile("profiles/DN9APW"))); + assertEquals(1, managementService.listProfiles().size()); + } + + @Test + void renamingKeepsTheIdentifierAndTherebyAllPaths() { + + OperatorProfile createdProfile = managementService.createProfile("DN9APW", false); + + assertTrue(managementService.renameProfile(createdProfile, "Philipp portable")); + + OperatorProfile renamedProfile = managementService.listProfiles().get(1); + + assertEquals("Philipp portable", renamedProfile.getDisplayName()); + assertEquals("DN9APW", renamedProfile.getProfileId()); + assertEquals("profiles/DN9APW/preferences.xml", + OperatorProfilePaths.preferencesRelativeFileName(renamedProfile)); + } + + private ChatPreferences preferencesAt(final String relativeFileName) { + ChatPreferences preferences = new ChatPreferences(relativeFileName); + preferences.readPreferencesFromXmlFile(); + return preferences; + } + + private Path applicationFile(final String relativeFileName) { + return temporaryHomeDirectory.resolve(".praktiKST").resolve(relativeFileName); + } +}