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);
+ }
+}