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