diff --git a/src/main/java/kst4contest/controller/ActiveOperatorProfile.java b/src/main/java/kst4contest/controller/ActiveOperatorProfile.java new file mode 100644 index 00000000..9a1febd5 --- /dev/null +++ b/src/main/java/kst4contest/controller/ActiveOperatorProfile.java @@ -0,0 +1,48 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfileSelection; + +/** + * Holds the operator profile the current runtime works with. + * + *
The state is deliberately static, which is safe here for reasons that did not apply + * to the former static database connection: the value is immutable, it holds no live + * resource, and it is set on the JavaFX Application Thread before anything reads it - + * during startup, and again during a profile switch after the previous runtime has been + * shut down completely.
+ */ +public final class ActiveOperatorProfile { + + private static volatile OperatorProfileSelection currentSelection; + + private ActiveOperatorProfile() { + // Utility class. + } + + /** + * Returns the active profile selection. + * + * @return the active selection, or null when startup has not resolved one yet + */ + public static OperatorProfileSelection get() { + return currentSelection; + } + + /** + * Sets the active profile selection. + * + * @param selection selection to activate + */ + public static void set(final OperatorProfileSelection selection) { + currentSelection = selection; + } + + /** + * Returns whether a profile has already been resolved for this runtime. + * + * @return true if a selection is present + */ + public static boolean isInitialized() { + return currentSelection != null; + } +} diff --git a/src/main/java/kst4contest/controller/OperatorProfilePaths.java b/src/main/java/kst4contest/controller/OperatorProfilePaths.java new file mode 100644 index 00000000..effdb2a6 --- /dev/null +++ b/src/main/java/kst4contest/controller/OperatorProfilePaths.java @@ -0,0 +1,199 @@ +package kst4contest.controller; + +import kst4contest.model.ChatPreferences; +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; + +import java.util.Collection; +import java.util.Locale; + +/** + * Derives the file names of an operator profile. + * + *This is the single place that knows how a profile maps onto files. The registry + * stores only the shared/own flag, never a path, so the two can never drift apart.
+ */ +public final class OperatorProfilePaths { + + /** + * Directory below the application directory that holds the additional profiles. + */ + public static final String PROFILES_DIRECTORY = "profiles"; + + /** + * Identifier of the profile that uses the historic flat installation layout. + */ + public static final String ROOT_PROFILE_ID = "default"; + + /** + * Maximum length of a generated profile identifier. + */ + private static final int MAX_PROFILE_ID_LENGTH = 32; + + private OperatorProfilePaths() { + // Utility class. + } + + /** + * Builds the profile descriptor of the historic flat installation. + * + * @param displayName name to show for the root profile + * @return the root profile descriptor + */ + public static OperatorProfile buildRootProfile(final String displayName) { + return new OperatorProfile(ROOT_PROFILE_ID, displayName, true, true); + } + + /** + * Returns the profile directory relative to the application directory. + * + * @param profile profile to resolve + * @return relative directory name + */ + public static String profileRelativeDirectory(final OperatorProfile profile) { + return PROFILES_DIRECTORY + "/" + profile.getProfileId(); + } + + /** + * Returns the preferences file name relative to the application directory. + * + * @param profile profile to resolve + * @return relative preferences file name + */ + public static String preferencesRelativeFileName(final OperatorProfile profile) { + + if (profile.isRootProfile()) { + return ChatPreferences.PREFERENCES_FILE; + } + + return profileRelativeDirectory(profile) + "/" + ChatPreferences.PREFERENCES_FILE; + } + + /** + * Returns the worked-station database file name relative to the application directory. + * + *A profile using the common station database always resolves to the historic flat + * file, which is what a multi operator station wants: the existing contest state stays + * the shared one.
+ * + * @param profile profile to resolve + * @return relative database file name + */ + public static String workedDatabaseRelativeFileName(final OperatorProfile profile) { + + if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) { + return DBController.DATABASE_FILE; + } + + return profileRelativeDirectory(profile) + "/" + DBController.DATABASE_FILE; + } + + /** + * Resolves a profile descriptor into the runtime selection used during startup. + * + * @param profile profile to resolve + * @return resolved selection + */ + public static OperatorProfileSelection resolve(final OperatorProfile profile) { + + boolean usesSharedStationDatabase = profile.isRootProfile() || profile.isSharedWorkedDatabase(); + + return new OperatorProfileSelection( + profile, + preferencesRelativeFileName(profile), + workedDatabaseRelativeFileName(profile), + usesSharedStationDatabase + ); + } + + /** + * Derives a stable, file system safe identifier from a display name. + * + *The identifier becomes a directory name and is never changed afterwards, so a + * later rename of the profile does not move any file.
+ * + * @param displayName name entered by the operator + * @param takenProfileIds identifiers that are already in use + * @return an identifier that is not yet taken + */ + public static String toProfileId(final String displayName, final CollectionThe registry file is created lazily. As long as an installation has only the + * historic flat layout, no registry exists and none is written, so a single operator + * installation behaves exactly as before. The file appears when the second profile is + * created; at that moment the root profile is materialised as well.
+ * + *A missing, unreadable or malformed registry is never fatal. It is logged and + * treated like an installation without additional profiles.
+ */ +public class OperatorProfileStore { + + private static final Logger LOGGER = Logger.getLogger(OperatorProfileStore.class.getName()); + + /** + * Name of the registry file inside the application directory. + */ + public static final String PROFILES_REGISTRY_FILE = "profiles.xml"; + + private static final String TAG_ROOT = "praktiKSTProfiles"; + private static final String TAG_REGISTRY_VERSION = "registryVersion"; + private static final String TAG_LAST_USED_PROFILE_ID = "lastUsedProfileId"; + private static final String TAG_PROFILE = "profile"; + private static final String TAG_PROFILE_ID = "profileId"; + private static final String TAG_DISPLAY_NAME = "displayName"; + private static final String TAG_ROOT_PROFILE = "rootProfile"; + private static final String TAG_SHARED_WORKED_DATABASE = "sharedWorkedDatabase"; + private static final String TAG_LAST_USED_EPOCH_MS = "lastUsedEpochMs"; + + private static final int REGISTRY_VERSION = 1; + + private final String registryFilePath; + + /** + * Creates a store working on the registry of the current installation. + */ + public OperatorProfileStore() { + this(ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, PROFILES_REGISTRY_FILE)); + } + + /** + * Creates a store working on an explicit registry file. + * + * @param registryFilePath absolute path of the registry file + */ + public OperatorProfileStore(final String registryFilePath) { + this.registryFilePath = registryFilePath; + } + + /** + * Returns whether a registry file exists at all. + * + * @return true if the installation already has more than the historic flat layout + */ + public boolean isRegistryPresent() { + return new File(registryFilePath).isFile(); + } + + /** + * Builds the in-memory descriptor of the historic flat installation. + * + *Nothing is written. This keeps a single operator installation untouched.
+ * + * @return the implicit root profile + */ + public OperatorProfile buildImplicitRootProfile() { + return OperatorProfilePaths.buildRootProfile("Default"); + } + + /** + * Reads all stored profiles. + * + * @return the stored profiles, or an empty list when no usable registry exists + */ + public ListDoes nothing when no registry exists, so a single operator installation is not + * turned into a multi profile installation by merely starting the application.
+ * + * @param profileId identifier of the activated profile + * @return true if the registry was updated + */ + public boolean recordLastUsed(final String profileId) { + + if (!isRegistryPresent()) { + return false; + } + + ListA profile always owns its own preferences file. Whether it also owns its own + * worked-station database is decided by {@link #isSharedWorkedDatabase()}: a multi + * operator contest station keeps one common log and therefore shares the database, + * while two operators sharing a private computer usually want their worked data kept + * apart.
+ * + *The descriptor deliberately carries no file paths. They are derived in exactly one + * place, {@link kst4contest.controller.OperatorProfilePaths}, so a stored path can never + * drift apart from the flag that produced it.
+ */ +public class OperatorProfile { + + /** + * Stable identifier of the profile. It is assigned once and never changes, so + * renaming a profile never moves a directory. + */ + private String profileId; + + /** + * Name shown in the profile picker and in the settings window. + */ + private String displayName; + + /** + * True for the profile that uses the historic flat installation layout directly. + */ + private boolean rootProfile; + + /** + * True if this profile uses the common station worked-station database. + */ + private boolean sharedWorkedDatabase; + + /** + * Timestamp of the last activation, used to preselect an entry in the picker. + */ + private long lastUsedEpochMs; + + public OperatorProfile() { + // Default constructor for stepwise construction while reading the registry. + } + + public OperatorProfile(final String profileId, + final String displayName, + final boolean rootProfile, + final boolean sharedWorkedDatabase) { + this.profileId = profileId; + this.displayName = displayName; + this.rootProfile = rootProfile; + this.sharedWorkedDatabase = sharedWorkedDatabase; + } + + public String getProfileId() { + return profileId; + } + + public void setProfileId(final String profileId) { + this.profileId = profileId; + } + + public String getDisplayName() { + return displayName; + } + + public void setDisplayName(final String displayName) { + this.displayName = displayName; + } + + public boolean isRootProfile() { + return rootProfile; + } + + public void setRootProfile(final boolean rootProfile) { + this.rootProfile = rootProfile; + } + + public boolean isSharedWorkedDatabase() { + return sharedWorkedDatabase; + } + + public void setSharedWorkedDatabase(final boolean sharedWorkedDatabase) { + this.sharedWorkedDatabase = sharedWorkedDatabase; + } + + public long getLastUsedEpochMs() { + return lastUsedEpochMs; + } + + public void setLastUsedEpochMs(final long lastUsedEpochMs) { + this.lastUsedEpochMs = lastUsedEpochMs; + } + + @Override + public boolean equals(final Object other) { + + if (this == other) { + return true; + } + + if (!(other instanceof OperatorProfile)) { + return false; + } + + return Objects.equals(profileId, ((OperatorProfile) other).profileId); + } + + @Override + public int hashCode() { + return Objects.hashCode(profileId); + } + + /** + * Returns the display name so the descriptor can be shown in a list control directly. + * + * @return the display name, or the profile id when no name was set + */ + @Override + public String toString() { + + if (displayName == null || displayName.isBlank()) { + return String.valueOf(profileId); + } + + return displayName; + } +} diff --git a/src/main/java/kst4contest/model/OperatorProfileSelection.java b/src/main/java/kst4contest/model/OperatorProfileSelection.java new file mode 100644 index 00000000..50d40375 --- /dev/null +++ b/src/main/java/kst4contest/model/OperatorProfileSelection.java @@ -0,0 +1,70 @@ +package kst4contest.model; + +import kst4contest.ApplicationConstants; +import kst4contest.utils.ApplicationFileUtils; + +import java.util.Objects; + +/** + * Resolved runtime view of the active operator profile. + * + *This is the only profile information the rest of the application needs: two file + * names relative to the application directory plus the flag whether a missing + * worked-station database may be seeded from the bundled template. Everything else is + * derived from the descriptor.
+ */ +public class OperatorProfileSelection { + + private final OperatorProfile profile; + private final String preferencesRelativeFileName; + private final String workedDatabaseRelativeFileName; + private final boolean seedWorkedDatabaseFromResource; + + public OperatorProfileSelection(final OperatorProfile profile, + final String preferencesRelativeFileName, + final String workedDatabaseRelativeFileName, + final boolean seedWorkedDatabaseFromResource) { + this.profile = Objects.requireNonNull(profile, "profile"); + this.preferencesRelativeFileName = + Objects.requireNonNull(preferencesRelativeFileName, "preferencesRelativeFileName"); + this.workedDatabaseRelativeFileName = + Objects.requireNonNull(workedDatabaseRelativeFileName, "workedDatabaseRelativeFileName"); + this.seedWorkedDatabaseFromResource = seedWorkedDatabaseFromResource; + } + + public OperatorProfile getProfile() { + return profile; + } + + public String getPreferencesRelativeFileName() { + return preferencesRelativeFileName; + } + + public String getWorkedDatabaseRelativeFileName() { + return workedDatabaseRelativeFileName; + } + + public boolean isSeedWorkedDatabaseFromResource() { + return seedWorkedDatabaseFromResource; + } + + /** + * Returns the absolute preferences path, for display in the settings window. + * + * @return absolute path of the preferences file + */ + public String getPreferencesAbsolutePath() { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, preferencesRelativeFileName); + } + + /** + * Returns the absolute worked-station database path, for display in the settings window. + * + * @return absolute path of the worked-station database + */ + public String getWorkedDatabaseAbsolutePath() { + return ApplicationFileUtils.getFilePath( + ApplicationConstants.APPLICATION_NAME, workedDatabaseRelativeFileName); + } +} diff --git a/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java b/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java new file mode 100644 index 00000000..c71f339e --- /dev/null +++ b/src/test/java/kst4contest/controller/OperatorProfilePathsTest.java @@ -0,0 +1,81 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfile; +import kst4contest.model.OperatorProfileSelection; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfilePathsTest { + + @Test + void rootProfileKeepsTheHistoricFlatFileNames() { + + OperatorProfileSelection resolved = + OperatorProfilePaths.resolve(OperatorProfilePaths.buildRootProfile("Default")); + + // This is the downgrade guard: an older KST4Contest release reads exactly these + // two files. If this test ever fails, existing installations would silently lose + // their configuration and worked data when the operator reverts a version. + assertEquals("preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + assertTrue(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void additionalProfileWithSharedDatabaseUsesItsOwnPreferencesButTheStationDatabase() { + + OperatorProfile sharedProfile = new OperatorProfile("OP2", "DN9APW", false, true); + OperatorProfileSelection resolved = OperatorProfilePaths.resolve(sharedProfile); + + assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + assertTrue(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void additionalProfileWithOwnDatabaseIsFullySeparatedAndNotSeeded() { + + OperatorProfile ownDatabaseProfile = new OperatorProfile("OP2", "DN9APW", false, false); + OperatorProfileSelection resolved = OperatorProfilePaths.resolve(ownDatabaseProfile); + + assertEquals("profiles/OP2/preferences.xml", resolved.getPreferencesRelativeFileName()); + assertEquals("profiles/OP2/praktiKST.db", resolved.getWorkedDatabaseRelativeFileName()); + + // Seeding would hand a new operator the several thousand callsigns of the + // bundled template database. + assertFalse(resolved.isSeedWorkedDatabaseFromResource()); + } + + @Test + void profileIdIsFileSystemSafe() { + + assertEquals("DN9APW", OperatorProfilePaths.toProfileId("dn9apw", Set.of())); + assertEquals("DM5M_CONTEST", OperatorProfilePaths.toProfileId("DM5M Contest", Set.of())); + assertEquals("A_B", OperatorProfilePaths.toProfileId("a/../b", Set.of())); + assertEquals("MULLER", OperatorProfilePaths.toProfileId("Müller", Set.of())); + assertEquals("OP", OperatorProfilePaths.toProfileId(" ", Set.of())); + assertEquals("OP", OperatorProfilePaths.toProfileId(null, Set.of())); + + String longName = "A".repeat(60); + assertEquals(32, OperatorProfilePaths.toProfileId(longName, Set.of()).length()); + } + + @Test + void profileIdNeverCollidesAndNeverClaimsTheRootIdentifier() { + + assertEquals("DN9APW_2", OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW"))); + assertEquals("DN9APW_3", + OperatorProfilePaths.toProfileId("DN9APW", List.of("DN9APW", "DN9APW_2"))); + + // "default" is reserved for the historic flat installation. + assertNotEquals(OperatorProfilePaths.ROOT_PROFILE_ID, + OperatorProfilePaths.toProfileId("default", Set.of())); + } +} diff --git a/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java b/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java new file mode 100644 index 00000000..4e23067f --- /dev/null +++ b/src/test/java/kst4contest/controller/OperatorProfileStoreTest.java @@ -0,0 +1,117 @@ +package kst4contest.controller; + +import kst4contest.model.OperatorProfile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class OperatorProfileStoreTest { + + @TempDir + Path temporaryDirectory; + + @Test + void missingRegistryIsNotAnErrorAndIsNotCreated() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + assertFalse(store.isRegistryPresent()); + assertTrue(store.loadProfiles().isEmpty()); + assertEquals(Optional.empty(), store.loadLastUsedProfileId()); + + // A single operator installation must stay untouched by merely starting up. + assertFalse(Files.exists(temporaryDirectory.resolve("profiles.xml"))); + } + + @Test + void profilesSurviveAWriteReadRoundTrip() { + + OperatorProfileStore store = storeAt("profiles.xml"); + + OperatorProfile rootProfile = OperatorProfilePaths.buildRootProfile("DM5M station"); + OperatorProfile secondProfile = new OperatorProfile("OP2", "DN9APW", false, false); + secondProfile.setLastUsedEpochMs(1757328000000L); + + assertTrue(store.saveProfiles(List.of(rootProfile, secondProfile), "OP2")); + assertTrue(store.isRegistryPresent()); + + List