mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-09-11 03:35:28 +02:00
Introduce the operator profile model and its registry
Adds the descriptor, the path derivation and the registry persistence for operator profiles. Nothing calls them yet, so behaviour is unchanged. The descriptor stores only a shared/own flag, never a path. All file names are derived in OperatorProfilePaths, so a stored path can never drift apart from the flag that produced it. A profile identifier is a stable, file system safe slug assigned once, so renaming a profile never moves a directory. The registry is created lazily. An installation that only has the historic flat layout gets no registry file and no profiles directory; the root profile is synthesised in memory instead. That keeps a single operator installation byte for byte the one it was before, and it keeps a downgrade to an older release a no-op. A missing, unreadable or malformed registry is logged and treated like an installation without additional profiles, never as an error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Hpa6bjie5qkeNG62y6FmXm
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.model.OperatorProfileSelection;
|
||||
|
||||
/**
|
||||
* Holds the operator profile the current runtime works with.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* <p>The identifier becomes a directory name and is never changed afterwards, so a
|
||||
* later rename of the profile does not move any file.</p>
|
||||
*
|
||||
* @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 Collection<String> takenProfileIds) {
|
||||
|
||||
StringBuilder sanitized = new StringBuilder();
|
||||
|
||||
if (displayName != null) {
|
||||
String foldedDisplayName = foldGermanUmlauts(displayName.toUpperCase(Locale.ROOT));
|
||||
|
||||
for (char currentCharacter : foldedDisplayName.toCharArray()) {
|
||||
boolean isAcceptable = (currentCharacter >= 'A' && currentCharacter <= 'Z')
|
||||
|| (currentCharacter >= '0' && currentCharacter <= '9')
|
||||
|| currentCharacter == '-';
|
||||
|
||||
if (isAcceptable) {
|
||||
sanitized.append(currentCharacter);
|
||||
} else if (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) != '_') {
|
||||
sanitized.append('_');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
while (sanitized.length() > 0 && sanitized.charAt(sanitized.length() - 1) == '_') {
|
||||
sanitized.setLength(sanitized.length() - 1);
|
||||
}
|
||||
|
||||
if (sanitized.length() > MAX_PROFILE_ID_LENGTH) {
|
||||
sanitized.setLength(MAX_PROFILE_ID_LENGTH);
|
||||
}
|
||||
|
||||
String candidate = sanitized.toString();
|
||||
|
||||
if (candidate.isEmpty() || ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
|
||||
candidate = "OP";
|
||||
}
|
||||
|
||||
if (!isProfileIdTaken(candidate, takenProfileIds)) {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
int suffix = 2;
|
||||
|
||||
while (isProfileIdTaken(candidate + "_" + suffix, takenProfileIds)) {
|
||||
suffix++;
|
||||
}
|
||||
|
||||
return candidate + "_" + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds German umlauts so a name like "Muller" written with an umlaut still produces a
|
||||
* readable identifier instead of a placeholder character.
|
||||
*
|
||||
* @param upperCaseText already upper-cased text
|
||||
* @return text with umlauts replaced by their base letters
|
||||
*/
|
||||
private static String foldGermanUmlauts(final String upperCaseText) {
|
||||
return upperCaseText
|
||||
.replace("\u00C4", "A")
|
||||
.replace("\u00D6", "O")
|
||||
.replace("\u00DC", "U")
|
||||
.replace("\u00DF", "SS");
|
||||
}
|
||||
|
||||
private static boolean isProfileIdTaken(final String candidate, final Collection<String> takenProfileIds) {
|
||||
|
||||
if (ROOT_PROFILE_ID.equalsIgnoreCase(candidate)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (takenProfileIds == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (String takenProfileId : takenProfileIds) {
|
||||
if (candidate.equalsIgnoreCase(takenProfileId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
package kst4contest.controller;
|
||||
|
||||
import kst4contest.ApplicationConstants;
|
||||
import kst4contest.model.OperatorProfile;
|
||||
import kst4contest.utils.ApplicationFileUtils;
|
||||
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.Node;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.XMLConstants;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import javax.xml.transform.OutputKeys;
|
||||
import javax.xml.transform.Transformer;
|
||||
import javax.xml.transform.TransformerFactory;
|
||||
import javax.xml.transform.dom.DOMSource;
|
||||
import javax.xml.transform.stream.StreamResult;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* Reads and writes the operator profile registry.
|
||||
*
|
||||
* <p>The 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.</p>
|
||||
*
|
||||
* <p>A missing, unreadable or malformed registry is never fatal. It is logged and
|
||||
* treated like an installation without additional profiles.</p>
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* <p>Nothing is written. This keeps a single operator installation untouched.</p>
|
||||
*
|
||||
* @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 List<OperatorProfile> loadProfiles() {
|
||||
|
||||
List<OperatorProfile> loadedProfiles = new ArrayList<>();
|
||||
Document document = readRegistryDocument();
|
||||
|
||||
if (document == null) {
|
||||
return loadedProfiles;
|
||||
}
|
||||
|
||||
NodeList profileNodes = document.getElementsByTagName(TAG_PROFILE);
|
||||
|
||||
for (int profileIndex = 0; profileIndex < profileNodes.getLength(); profileIndex++) {
|
||||
Node currentNode = profileNodes.item(profileIndex);
|
||||
|
||||
if (currentNode.getNodeType() != Node.ELEMENT_NODE) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Element profileElement = (Element) currentNode;
|
||||
String profileId = readText(profileElement, TAG_PROFILE_ID);
|
||||
|
||||
if (profileId == null || profileId.isBlank()) {
|
||||
LOGGER.log(Level.WARNING, "Skipping operator profile entry without an identifier");
|
||||
continue;
|
||||
}
|
||||
|
||||
OperatorProfile loadedProfile = new OperatorProfile();
|
||||
loadedProfile.setProfileId(profileId.trim());
|
||||
loadedProfile.setDisplayName(readText(profileElement, TAG_DISPLAY_NAME));
|
||||
loadedProfile.setRootProfile(readBoolean(profileElement, TAG_ROOT_PROFILE, false));
|
||||
loadedProfile.setSharedWorkedDatabase(
|
||||
readBoolean(profileElement, TAG_SHARED_WORKED_DATABASE, true));
|
||||
loadedProfile.setLastUsedEpochMs(readLong(profileElement, TAG_LAST_USED_EPOCH_MS));
|
||||
|
||||
if (loadedProfile.getDisplayName() == null || loadedProfile.getDisplayName().isBlank()) {
|
||||
loadedProfile.setDisplayName(loadedProfile.getProfileId());
|
||||
}
|
||||
|
||||
// The root profile always uses the common station database, because its
|
||||
// database is the historic flat file itself.
|
||||
if (loadedProfile.isRootProfile()) {
|
||||
loadedProfile.setSharedWorkedDatabase(true);
|
||||
}
|
||||
|
||||
loadedProfiles.add(loadedProfile);
|
||||
}
|
||||
|
||||
return loadedProfiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the identifier of the profile that was activated last.
|
||||
*
|
||||
* @return the identifier, or empty when unknown
|
||||
*/
|
||||
public Optional<String> loadLastUsedProfileId() {
|
||||
|
||||
Document document = readRegistryDocument();
|
||||
|
||||
if (document == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
Element rootElement = document.getDocumentElement();
|
||||
|
||||
if (rootElement == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
String lastUsedProfileId = readText(rootElement, TAG_LAST_USED_PROFILE_ID);
|
||||
|
||||
if (lastUsedProfileId == null || lastUsedProfileId.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
return Optional.of(lastUsedProfileId.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the complete registry.
|
||||
*
|
||||
* @param profiles profiles to store
|
||||
* @param lastUsedProfileId identifier of the profile that was activated last, may be null
|
||||
* @return true if the registry was written
|
||||
*/
|
||||
public boolean saveProfiles(final List<OperatorProfile> profiles, final String lastUsedProfileId) {
|
||||
|
||||
try {
|
||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
||||
DocumentBuilder documentBuilder = documentBuilderFactory.newDocumentBuilder();
|
||||
Document document = documentBuilder.newDocument();
|
||||
|
||||
Element rootElement = document.createElement(TAG_ROOT);
|
||||
document.appendChild(rootElement);
|
||||
|
||||
appendTextElement(document, rootElement, TAG_REGISTRY_VERSION, String.valueOf(REGISTRY_VERSION));
|
||||
|
||||
if (lastUsedProfileId != null && !lastUsedProfileId.isBlank()) {
|
||||
appendTextElement(document, rootElement, TAG_LAST_USED_PROFILE_ID, lastUsedProfileId);
|
||||
}
|
||||
|
||||
for (OperatorProfile currentProfile : profiles) {
|
||||
Element profileElement = document.createElement(TAG_PROFILE);
|
||||
rootElement.appendChild(profileElement);
|
||||
|
||||
appendTextElement(document, profileElement, TAG_PROFILE_ID, currentProfile.getProfileId());
|
||||
appendTextElement(document, profileElement, TAG_DISPLAY_NAME, currentProfile.getDisplayName());
|
||||
appendTextElement(document, profileElement, TAG_ROOT_PROFILE,
|
||||
String.valueOf(currentProfile.isRootProfile()));
|
||||
appendTextElement(document, profileElement, TAG_SHARED_WORKED_DATABASE,
|
||||
String.valueOf(currentProfile.isRootProfile() || currentProfile.isSharedWorkedDatabase()));
|
||||
appendTextElement(document, profileElement, TAG_LAST_USED_EPOCH_MS,
|
||||
String.valueOf(currentProfile.getLastUsedEpochMs()));
|
||||
}
|
||||
|
||||
return writeDocumentAtomically(document);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "Could not write the operator profile registry", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records that a profile has been activated.
|
||||
*
|
||||
* <p>Does nothing when no registry exists, so a single operator installation is not
|
||||
* turned into a multi profile installation by merely starting the application.</p>
|
||||
*
|
||||
* @param profileId identifier of the activated profile
|
||||
* @return true if the registry was updated
|
||||
*/
|
||||
public boolean recordLastUsed(final String profileId) {
|
||||
|
||||
if (!isRegistryPresent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
List<OperatorProfile> storedProfiles = loadProfiles();
|
||||
|
||||
if (storedProfiles.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (OperatorProfile currentProfile : storedProfiles) {
|
||||
if (currentProfile.getProfileId().equalsIgnoreCase(profileId)) {
|
||||
currentProfile.setLastUsedEpochMs(System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
|
||||
return saveProfiles(storedProfiles, profileId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the absolute path of the registry file.
|
||||
*
|
||||
* @return absolute registry path
|
||||
*/
|
||||
public String getRegistryFilePath() {
|
||||
return registryFilePath;
|
||||
}
|
||||
|
||||
private Document readRegistryDocument() {
|
||||
|
||||
File registryFile = new File(registryFilePath);
|
||||
|
||||
if (!registryFile.isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
|
||||
documentBuilderFactory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
||||
|
||||
return documentBuilderFactory.newDocumentBuilder().parse(registryFile);
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.WARNING,
|
||||
"Could not read the operator profile registry, continuing without additional profiles", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String readText(final Element parentElement, final String tagName) {
|
||||
|
||||
NodeList matchingNodes = parentElement.getElementsByTagName(tagName);
|
||||
|
||||
if (matchingNodes.getLength() == 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matchingNodes.item(0).getTextContent();
|
||||
}
|
||||
|
||||
private static boolean readBoolean(final Element parentElement,
|
||||
final String tagName,
|
||||
final boolean defaultValue) {
|
||||
|
||||
String rawValue = readText(parentElement, tagName);
|
||||
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return Boolean.parseBoolean(rawValue.trim());
|
||||
}
|
||||
|
||||
private static long readLong(final Element parentElement, final String tagName) {
|
||||
|
||||
String rawValue = readText(parentElement, tagName);
|
||||
|
||||
if (rawValue == null || rawValue.isBlank()) {
|
||||
return 0L;
|
||||
}
|
||||
|
||||
try {
|
||||
return Long.parseLong(rawValue.trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static void appendTextElement(final Document document,
|
||||
final Element parentElement,
|
||||
final String tagName,
|
||||
final String textContent) {
|
||||
|
||||
Element createdElement = document.createElement(tagName);
|
||||
createdElement.setTextContent(textContent == null ? "" : textContent);
|
||||
parentElement.appendChild(createdElement);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the registry through a temporary file so a crash can never leave a
|
||||
* half-written registry behind. This mirrors the established preferences writer.
|
||||
*
|
||||
* @param document document to write
|
||||
* @return true if the registry file was replaced
|
||||
*/
|
||||
private boolean writeDocumentAtomically(final Document document) {
|
||||
|
||||
Path targetPath = Path.of(registryFilePath).toAbsolutePath();
|
||||
Path parentDirectory = targetPath.getParent();
|
||||
|
||||
try {
|
||||
if (parentDirectory != null) {
|
||||
Files.createDirectories(parentDirectory);
|
||||
}
|
||||
|
||||
Path temporaryPath = Files.createTempFile(
|
||||
parentDirectory, PROFILES_REGISTRY_FILE, ".tmp");
|
||||
|
||||
Transformer transformer = TransformerFactory.newInstance().newTransformer();
|
||||
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
|
||||
transformer.setOutputProperty("{http://xml.apache.org/xslt}indent-amount", "4");
|
||||
|
||||
try (OutputStream outputStream = Files.newOutputStream(temporaryPath)) {
|
||||
transformer.transform(new DOMSource(document), new StreamResult(outputStream));
|
||||
}
|
||||
|
||||
try {
|
||||
Files.move(temporaryPath, targetPath,
|
||||
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException atomicMoveUnsupported) {
|
||||
Files.move(temporaryPath, targetPath, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
LOGGER.log(Level.SEVERE, "Could not store the operator profile registry", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package kst4contest.model;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Descriptor of one operator profile.
|
||||
*
|
||||
* <p>A 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.</p>
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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<OperatorProfile> restored = store.loadProfiles();
|
||||
|
||||
assertEquals(2, restored.size());
|
||||
assertEquals("default", restored.get(0).getProfileId());
|
||||
assertEquals("DM5M station", restored.get(0).getDisplayName());
|
||||
assertTrue(restored.get(0).isRootProfile());
|
||||
assertTrue(restored.get(0).isSharedWorkedDatabase());
|
||||
|
||||
assertEquals("OP2", restored.get(1).getProfileId());
|
||||
assertEquals("DN9APW", restored.get(1).getDisplayName());
|
||||
assertFalse(restored.get(1).isRootProfile());
|
||||
assertFalse(restored.get(1).isSharedWorkedDatabase());
|
||||
assertEquals(1757328000000L, restored.get(1).getLastUsedEpochMs());
|
||||
|
||||
assertEquals(Optional.of("OP2"), store.loadLastUsedProfileId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicWriteLeavesNoTemporaryFileBehind() throws IOException {
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
store.saveProfiles(List.of(OperatorProfilePaths.buildRootProfile("Default")), "default");
|
||||
|
||||
try (var directoryEntries = Files.list(temporaryDirectory)) {
|
||||
assertTrue(directoryEntries.noneMatch(entry -> entry.getFileName().toString().endsWith(".tmp")));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedRegistryFallsBackToNoAdditionalProfiles() throws IOException {
|
||||
|
||||
Path registryFile = temporaryDirectory.resolve("profiles.xml");
|
||||
Files.writeString(registryFile, "<praktiKSTProfiles><profile><profileId>OP2");
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
|
||||
assertTrue(store.isRegistryPresent());
|
||||
assertTrue(store.loadProfiles().isEmpty());
|
||||
assertEquals(Optional.empty(), store.loadLastUsedProfileId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void entriesWithoutAnIdentifierAreSkippedInsteadOfBreakingTheRegistry() throws IOException {
|
||||
|
||||
Path registryFile = temporaryDirectory.resolve("profiles.xml");
|
||||
Files.writeString(registryFile,
|
||||
"<praktiKSTProfiles>"
|
||||
+ "<profile><displayName>broken</displayName></profile>"
|
||||
+ "<profile><profileId>OP2</profileId><displayName>DN9APW</displayName>"
|
||||
+ "<sharedWorkedDatabase>false</sharedWorkedDatabase></profile>"
|
||||
+ "</praktiKSTProfiles>");
|
||||
|
||||
List<OperatorProfile> restored = storeAt("profiles.xml").loadProfiles();
|
||||
|
||||
assertEquals(1, restored.size());
|
||||
assertEquals("OP2", restored.get(0).getProfileId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordLastUsedDoesNothingWhenNoRegistryExists() {
|
||||
|
||||
OperatorProfileStore store = storeAt("profiles.xml");
|
||||
|
||||
assertFalse(store.recordLastUsed("default"));
|
||||
assertFalse(store.isRegistryPresent());
|
||||
}
|
||||
|
||||
private OperatorProfileStore storeAt(final String fileName) {
|
||||
return new OperatorProfileStore(temporaryDirectory.resolve(fileName).toString());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user