Repository: kit-sdq/TimeSheetGenerator
Pull request #222: Feature/UI | Custom file format
Author: login: Just1Developer
Created: 2025-05-20T14:19:44Z | Merged: 2025-05-23T11:15:08Z

Some additional fixes and one feature I've been wanting to do for a long time. CLI stuff is coming I promise.

### New features
- Finally added a custom format for the default file name. A list of Placeholders is available in the UI.

### Fixes
- Changed preset settings: Algo and Proggen are now what is actually demanded. Unrelated settings are not changed
- New entries are now not added anymore as empty entries when `Cancel` is pressed

### Issues
- Resolves #220 

### Planned for the future
- More CLI Options, like --noHoliday for removing the explicit holiday entry
- Offline Verification for exporting timesheets if you don't have internet
- Soft Auto-fill for weekly or bi-weekly events (please)

Linked issue #220: Canceled entry is added anyways
When making a new entry, you can cancel and it will abort entry. However, what actually happens is it adds an empty entry to the sheet. This is because the details window does not distinguish between *new* and *edit*.

This can be fixed by:
a) Carrying the information if it's creating a new entry or editing an existing one and modifying behavior based off of that or
b) Simply ignoring add-calls for empty entries. When pressing cancel, the "old" entry is sent for re-submission, as the old one needs to be deleted and that currently has to happen when opening the entry. When pressing cancel on new entries, it will always be an empty entry, which would automatically disqualify it.

*I am currently already working on this issue, but this is sort of a reminder and to spread some sort of awareness that the bug is there.*

The fix will arrive together with some other fixes/features/etc before the end of the month. I'm sorry I missed this.

Diff:
diff --git a/src/main/java/ui/DialogHelper.java b/src/main/java/ui/DialogHelper.java
index e2c2a60..5b91b4f 100644
--- a/src/main/java/ui/DialogHelper.java
+++ b/src/main/java/ui/DialogHelper.java
@@ -222,9 +222,7 @@ public void focusLost(FocusEvent e) {
 				dialog.dispose();
 		});
 
-		cancelButton.addActionListener(e -> {
-			discardChanges(entry, parentUi, dialog);
-		});
+		cancelButton.addActionListener(e -> discardChanges(entry, parentUi, dialog));
 
 		dialog.addWindowListener(new WindowAdapter() {
 			@Override
@@ -367,7 +365,8 @@ public static void addPlaceholderText(JTextComponent component, String placehold
 		component.addFocusListener(new FocusAdapter() {
 			@Override
 			public void focusGained(FocusEvent e) {
-				if (component.getText().equals(placeholder)) {
+				// Gray Color <=> Text is placeholder and should be treated as such
+				if (component.getForeground().equals(Color.GRAY)) {
 					component.setText("");
 					component.setForeground(Color.BLACK);
 				}
diff --git a/src/main/java/ui/GlobalSettingsDialog.java b/src/main/java/ui/GlobalSettingsDialog.java
index 3e5a456..ba35d65 100644
--- a/src/main/java/ui/GlobalSettingsDialog.java
+++ b/src/main/java/ui/GlobalSettingsDialog.java
@@ -21,10 +21,13 @@ private GlobalSettingsDialog() {
 
 	private static final int SCROLL_SENSITIVITY = 16;
 
+	private static final int TEXTBOXES_COUNT = 6;
+	private static final int TEXTFIELD_INDEX_PDF_FORMAT = 5;
+
 	public static void showGlobalSettingsDialog(UserInterface parentUI) {
 		JDialog dialog = new JDialog();
 		dialog.setTitle("Global Settings");
-		dialog.setSize(600, 485);
+		dialog.setSize(690, 525);
 		dialog.setModal(true);
 		dialog.setLocationRelativeTo(null); // Center the dialog
 
@@ -44,7 +47,7 @@ public static void showGlobalSettingsDialog(UserInterface parentUI) {
 		JLabel[] errorLabels = new JLabel[7];
 
 		// Fields array for text fields
-		JTextField[] fields = new JTextField[5];
+		JTextField[] fields = new JTextField[TEXTBOXES_COUNT];
 		JComboBox<String> workAreaSelector = new JComboBox<>();
 		workAreaSelector.addItem(WORK_AREA_UB);
 		workAreaSelector.addItem(WORK_AREA_GF);
@@ -52,23 +55,23 @@ public static void showGlobalSettingsDialog(UserInterface parentUI) {
 
 		// Checkboxes
 		JCheckBox addSignatureBox = new JCheckBox();
-		JCheckBox addVacationEntryBox = new JCheckBox();
 		JCheckBox useYYYYBox = new JCheckBox();
+		JCheckBox addVacationEntryBox = new JCheckBox();
 		JCheckBox useGermanMonthNameBox = new JCheckBox();
 		JCheckBox warnHoursMismatchBox = new JCheckBox();
 		addSignatureBox.setSelected(uiSettings.isAddSignature());
-		addVacationEntryBox.setSelected(uiSettings.isAddVacationEntry());
 		useYYYYBox.setSelected(uiSettings.isUseYYYY());
+		addVacationEntryBox.setSelected(uiSettings.isAddVacationEntry());
 		useGermanMonthNameBox.setSelected(uiSettings.isUseGermanMonths());
 		warnHoursMismatchBox.setSelected(uiSettings.isWarnOnHoursMismatch());
 
-		final int TEXTBOXES_COUNT = 5;
-
-		String[] labels = { "Name:", "Staff ID:", "Department:", "Working Time:", "Wage:", "Working Area:", "Add Signature at Bottom:",
-				"Explicitly add Vacation Entry:", "Use 4-digit year in the day column:", "Use German month names", "Warn when too few/ too many hours:" };
-		String[] placeholders = { "Enter your name", "Enter your staff ID", "Enter your department", "Enter working time (HH:MM)", "Enter your wage" };
+		String[] labels = { "Name:", "Staff ID:", "Department:", "Working Time:", "Wage:", "PDF Name Format:", "Working Area:", "Add Signature at Bottom:",
+				"Explicitly add Vacation Entry:", "Use 4-digit year in the day column:", "Use German months in Sheet header",
+				"Warn when too few/ too many hours:" };
+		String[] placeholders = { "Enter your name", "Enter your staff ID", "Enter your department", "Enter working time (HH:MM)", "Enter your wage",
+				uiSettings.getExportPdfNameFormat() };
 		String[] initialValues = { globalSettings.getName(), String.valueOf(globalSettings.getStaffId()), globalSettings.getDepartment(),
-				globalSettings.getWorkingTime(), String.valueOf(globalSettings.getWage()) };
+				globalSettings.getWorkingTime(), String.valueOf(globalSettings.getWage()), uiSettings.getExportPdfNameFormat() };
 		JCheckBox[] checkBoxes = { addSignatureBox, addVacationEntryBox, useYYYYBox, useGermanMonthNameBox, warnHoursMismatchBox };
 
 		for (int i = 0; i < labels.length; i++) {
@@ -106,6 +109,14 @@ public void focusLost(FocusEvent e) {
 						validateField(fields[index], errorLabels[index], index);
 					}
 				});
+				if (i == TEXTFIELD_INDEX_PDF_FORMAT) {
+					JButton helpButton = new JButton("Format Help");
+					gbc.gridx = 3;
+					gbc.gridy = row;
+					gbc.weightx = 0;
+					helpButton.addActionListener(l -> showPdfFormatHelp(dialog));
+					panel.add(helpButton, gbc);
+				}
 			} else if (i == TEXTBOXES_COUNT) {
 				panel.add(workAreaSelector, gbc);
 			} else {
@@ -122,19 +133,21 @@ public void focusLost(FocusEvent e) {
 		buttonPanel.add(saveButton);
 		buttonPanel.add(cancelButton);
 
-		// Buttons at the bottom
+		// Preset buttons at the bottom
 		JPanel presetButtonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
 		JButton presetProggenButton = new JButton("Proggen I Preset");
 		JButton presetAlgoButton = new JButton("Algo I Preset");
 		presetProggenButton.addActionListener((e) -> {
 			addVacationEntryBox.setSelected(false);
-			useYYYYBox.setSelected(false);
-			useGermanMonthNameBox.setSelected(false);
+			JTextField pdfFormatField = fields[TEXTFIELD_INDEX_PDF_FORMAT];
+			pdfFormatField.setText(JSONHandler.DEFAULT_PDF_NAME_FORMAT_PROG);
+			pdfFormatField.setForeground(Color.BLACK);
 		});
 		presetAlgoButton.addActionListener((e) -> {
 			addVacationEntryBox.setSelected(true);
-			useYYYYBox.setSelected(true);
-			useGermanMonthNameBox.setSelected(true);
+			JTextField pdfFormatField = fields[TEXTFIELD_INDEX_PDF_FORMAT];
+			pdfFormatField.setText(JSONHandler.DEFAULT_PDF_NAME_FORMAT_ALGO);
+			pdfFormatField.setForeground(Color.BLACK);
 		});
 		presetButtonPanel.add(presetProggenButton);
 		presetButtonPanel.add(presetAlgoButton);
@@ -152,42 +165,13 @@ public void focusLost(FocusEvent e) {
 
 		// Action listeners for buttons
 		saveButton.addActionListener(e -> {
-			boolean hasError = false;
-			// Validate all fields
-			for (int i = 0; i < fields.length; i++) {
-				validateField(fields[i], errorLabels[i], i);
-				if (!errorLabels[i].getText().isBlank()) {
-					hasError = true;
-				}
-			}
-
-			if (hasError) {
+			boolean saved = trySaveNewGlobalSettings(parentUI, globalSettings, uiSettings, fields, errorLabels, workAreaSelector, checkBoxes);
+			if (saved) {
+				dialog.dispose();
+			} else {
 				// Show error message
 				JOptionPane.showMessageDialog(dialog, "Please fix the errors before saving.", "Validation Error", JOptionPane.ERROR_MESSAGE);
-				return;
 			}
-
-			// Update globalSettings object
-			globalSettings.setName(fields[0].getText());
-			globalSettings.setStaffId(Integer.parseInt(fields[1].getText()));
-			globalSettings.setDepartment(fields[2].getText());
-			globalSettings.setWorkingTime(fields[3].getText());
-			globalSettings.setWage(Double.parseDouble(fields[4].getText()));
-			globalSettings.setWorkingArea(getConfigValue(workAreaSelector.getSelectedItem()));
-
-			uiSettings.setAddSignature(addSignatureBox.isSelected());
-			uiSettings.setAddVacationEntry(addVacationEntryBox.isSelected());
-			uiSettings.setUseYYYY(useYYYYBox.isSelected());
-			uiSettings.setUseGermanMonths(useGermanMonthNameBox.isSelected());
-			uiSettings.setWarnOnHoursMismatch(warnHoursMismatchBox.isSelected());
-
-			// Save globalSettings to file or database as needed
-			JSONHandler.saveGlobal(globalSettings);
-			JSONHandler.saveUISettings(uiSettings);
-
-			parentUI.updateTotalTimeWorkedUI();
-
-			dialog.dispose();
 		});
 
 		cancelButton.addActionListener(e -> dialog.dispose());
@@ -200,6 +184,84 @@ public void focusLost(FocusEvent e) {
 		dialog.setVisible(true);
 	}
 
+	/**
+	 * Validates and saves the new settings. If validation fails, the method will
+	 * not override the existing settings and instead return false. If validation
+	 * succeeds, returns true.
+	 * 
+	 * @param parentUI         the parent user interface, used for updating the time
+	 *                         UI
+	 * @param globalSettings   the new global settings, containing the updated
+	 *                         settings
+	 * @param uiSettings       the new ui settings, containing the updated settings
+	 * @param fields           the text fields for text values like name or format.
+	 *                         Must have size exactly 5, otherwise will be ignored.
+	 * @param errorLabels      the error labels for displaying validation errors.
+	 *                         Must have size exactly 5, otherwise will be ignored.
+	 * @param workAreaSelector The combo box for selecting the working area (UB /
+	 *                         GF)
+	 * @param checkBoxes       the checkboxes for boolean values like addSignature
+	 *                         or addVacationEntry. Must have size exactly 5,
+	 *                         otherwise will be ignored.
+	 * @return true if settings are valid and saved successfully, false otherwise
+	 */
+	private static boolean trySaveNewGlobalSettings(UserInterface parentUI, Global globalSettings, UISettings uiSettings, JTextField[] fields,
+			JLabel[] errorLabels, JComboBox<String> workAreaSelector, JCheckBox[] checkBoxes) {
+		boolean hasError = false;
+		// Validate all fields
+		for (int i = 0; i < Math.min(fields.length, errorLabels.length); i++) {
+			validateField(fields[i], errorLabels[i], i);
+			if (!errorLabels[i].getText().isBlank()) {
+				hasError = true;
+			}
+		}
+		if (hasError)
+			return false;
+
+		// Update globalSettings object
+		if (fields.length == 5) {
+			globalSettings.setName(fields[0].getText());
+			globalSettings.setStaffId(Integer.parseInt(fields[1].getText()));
+			globalSettings.setDepartment(fields[2].getText());
+			globalSettings.setWorkingTime(fields[3].getText());
+			globalSettings.setWage(Double.parseDouble(fields[4].getText()));
+		}
+		globalSettings.setWorkingArea(getConfigValue(workAreaSelector.getSelectedItem()));
+
+		if (checkBoxes.length == 5) {
+			uiSettings.setAddSignature(checkBoxes[0].isSelected());
+			uiSettings.setAddVacationEntry(checkBoxes[1].isSelected());
+			uiSettings.setUseYYYY(checkBoxes[2].isSelected());
+			uiSettings.setUseGermanMonths(checkBoxes[3].isSelected());
+			uiSettings.setWarnOnHoursMismatch(checkBoxes[4].isSelected());
+		}
+		if (fields.length > TEXTFIELD_INDEX_PDF_FORMAT)
+			uiSettings.setExportPdfNameFormat(fields[TEXTFIELD_INDEX_PDF_FORMAT].getText());
+
+		// Save globalSettings to file or database as needed
+		JSONHandler.saveGlobal(globalSettings);
+		JSONHandler.saveUISettings(uiSettings);
+
+		parentUI.updateTotalTimeWorkedUI();
+		return true;
+	}
+
+	private static void showPdfFormatHelp(JDialog parentDialog) {
+		String message = """
+				You can use the following placeholders in the PDF name format:
+
+				- %FIRST%: First- and middle names, separated by space
+				- %FIRST_U%: First- and middle names, separated by underscores
+				- %LAST%: Lastname
+				- %MM%: Month number from 01-12
+				- %MM_GER%: German Month Name
+				- %MM_ENG%: English Month Name
+				- %YY%: Year (2 digits)
+				- %YYYY%: Year (4 digits)
+				""";
+		JOptionPane.showMessageDialog(parentDialog, message, "PDF Name Format Help", JOptionPane.INFORMATION_MESSAGE);
+	}
+
 	private static int getIndexValue(String configValue) {
 		if (configValue == null)
 			return 0;
diff --git a/src/main/java/ui/MonthlySettingsBar.java b/src/main/java/ui/MonthlySettingsBar.java
index b479181..f387940 100644
--- a/src/main/java/ui/MonthlySettingsBar.java
+++ b/src/main/java/ui/MonthlySettingsBar.java
@@ -154,6 +154,10 @@ public String getYearStr() {
 		return String.valueOf(getYear());
 	}
 
+	public String getFullYearStr() {
+		return String.valueOf(getFullYear());
+	}
+
 	public Time getPredTime() {
 		return predTimeField.isValid() ? Time.parseTime(predTimeField.getText()) : new Time(0, 0);
 	}
diff --git a/src/main/java/ui/TimesheetEntry.java b/src/main/java/ui/TimesheetEntry.java
index d6119cc..7955eae 100644
--- a/src/main/java/ui/TimesheetEntry.java
+++ b/src/main/java/ui/TimesheetEntry.java
@@ -180,6 +180,10 @@ public boolean isEmpty() {
 		return fromHour == -1 && fromMinute == -1 && toHour == -1 && toMinute == -1;
 	}
 
+	public boolean isNone() {
+		return activity.isEmpty();
+	}
+
 	public boolean isLaterThan(TimesheetEntry other) {
 		if (this.day < other.day)
 			return false;
diff --git a/src/main/java/ui/UserInterface.java b/src/main/java/ui/UserInterface.java
index 8545825..99aa3a8 100644
--- a/src/main/java/ui/UserInterface.java
+++ b/src/main/java/ui/UserInterface.java
@@ -182,6 +182,10 @@ public String getYear() {
 		return monthSettingsBar.getYearStr();
 	}
 
+	public String getFullYear() {
+		return monthSettingsBar.getFullYearStr();
+	}
+
 	public boolean hasUnsavedChanges() {
 		return hasUnsavedChanges;
 	}
@@ -334,6 +338,8 @@ public boolean isSpaceForNewEntry() {
 	}
 
 	public void addEntry(TimesheetEntry entry) {
+		if (entry.isNone())
+			return;
 		for (int i = 0; i < listModel.getSize(); i++) {
 			if (listModel.getElementAt(i).isLaterThan(entry)) {
 				listModel.insertElementAt(entry, i);
diff --git a/src/main/java/ui/fileexplorer/FileChooser.java b/src/main/java/ui/fileexplorer/FileChooser.java
index 2248129..5789634 100644
--- a/src/main/java/ui/fileexplorer/FileChooser.java
+++ b/src/main/java/ui/fileexplorer/FileChooser.java
@@ -2,7 +2,9 @@
 package ui.fileexplorer;
 
 import ui.UserInterface;
+import ui.json.Global;
 import ui.json.JSONHandler;
+import ui.json.UISettings;
 
 import javax.swing.*;
 import javax.swing.filechooser.FileNameExtensionFilter;
@@ -14,12 +16,20 @@ private FileChooser() {
 		// Don't allow instances of this class
 	}
 
-	// Timesheet Format: Nachname_Vorname_Monat Jahr.pdf
-	private static final String FORMAT = "%s_%s 20%s";
-
+	/**
+	 * Returns the default file name for the PDF export based on the users settings.
+	 *
+	 * @param parentUI The UserInterface instance to get the current month and year
+	 *                 from.
+	 * @return The default file name for the PDF export.
+	 */
 	public static String getDefaultFileName(UserInterface parentUI) {
-		return FORMAT.formatted(JSONHandler.getGlobalSettings().getNameUnderscoreFormat(), "%02d".formatted(parentUI.getCurrentMonthNumber()),
-				parentUI.getYear());
+		UISettings uiSettings = JSONHandler.getUISettings();
+		Global global = JSONHandler.getGlobalSettings();
+		return uiSettings.getExportPdfNameFormat().replace("%FIRST_U%", global.getFirstnameUnderscoreFormat()).replace("%FIRST%", global.getFirstname())
+				.replace("%LAST%", global.getLastname()).replace("%MM%", "%02d".formatted(parentUI.getCurrentMonthNumber()))
+				.replace("%MM_GER%", parentUI.getCurrentMonth().getGermanName()).replace("%MM_ENG%", parentUI.getCurrentMonthName())
+				.replace("%YY%", parentUI.getYear()).replace("%YYYY%", parentUI.getFullYear());
 	}
 
 	public static File chooseFile(String title, FileChooserType chooserType) {
diff --git a/src/main/java/ui/json/Global.java b/src/main/java/ui/json/Global.java
index b05c816..1b799cd 100644
--- a/src/main/java/ui/json/Global.java
+++ b/src/main/java/ui/json/Global.java
@@ -51,46 +51,74 @@ public String getNameFormalFormat() {
 		if (!getName().contains(" ")) {
 			return getName();
 		}
-
-		StringBuilder formattedName = new StringBuilder();
-		String[] nameParts = getName().split(" ");
-
-		formattedName.append(nameParts[nameParts.length - 1]).append(",");
-		for (int i = 0; i < nameParts.length - 1; ++i) {
-			formattedName.append(" ").append(nameParts[i]);
-		}
-
-		return formattedName.toString();
+		return "%s, %s".formatted(getLastname(), getFirstname());
 	}
 
 	/**
-	 * Formats the name, e.g. "Firstname-middle Lastname", to be
-	 * "Lastname_Firstname-middle" by splitting the name at the spaces and rotating
-	 * the last String to the front. Everything else is interpreted as first- and
+	 * Formats the name, e.g. "Firstname Middlename Lastname", to be "Firstname
+	 * middle" by splitting the name at the spaces and removing the last String, as
+	 * this is probably the lastname. Everything else is interpreted as first- and
 	 * middle names. <br/>
-	 * This method is similar to {@link Global#getNameFormalFormat()}, but it
-	 * separates the formatted name using underscores, and does not add a comma
-	 * after the lastname. <br/>
-	 * This is used when determining the filename when exporting to a PDF. <br/>
+	 * For separated by underscores instead of spaces, see
+	 * {@link Global#getFirstnameUnderscoreFormat()} This is used when determining
+	 * the filename when exporting to a PDF. <br/>
+	 *
 	 * If the name does not contain any spaces, returns the regular name.
 	 *
-	 * @return Like {@link Global#getNameFormalFormat()}, but with underscores and
-	 *         no comma.
+	 * @return the firstname of the employee, separated by spaces.
 	 */
 	@JsonIgnore
-	public String getNameUnderscoreFormat() {
+	public String getFirstname() {
 		if (!getName().contains(" ")) {
 			return getName();
 		}
 
-		StringBuilder formattedName = new StringBuilder();
 		String[] nameParts = getName().split(" ");
+		String[] onlyFirstnameParts = new String[nameParts.length - 1];
+		System.arraycopy(nameParts, 0, onlyFirstnameParts, 0, onlyFirstnameParts.length);
 
-		formattedName.append(nameParts[nameParts.length - 1]);
-		for (int i = 0; i < nameParts.length - 1; ++i) {
-			formattedName.append("_").append(nameParts[i]);
-		}
+		return String.join(" ", onlyFirstnameParts);
+	}
 
-		return formattedName.toString();
+	/**
+	 * Formats the name, e.g. "Firstname Middlename Lastname", to be "Firstname
+	 * middle" by splitting the name at the spaces and removing the last String, as
+	 * this is probably the lastname. Everything else is interpreted as first- and
+	 * middle names. <br/>
+	 *
+	 * For separated by spaces instead of underscores, see
+	 * {@link Global#getFirstname()} This is used when determining the filename when
+	 * exporting to a PDF. <br/>
+	 *
+	 * <p>
+	 * This method literally does {@code Global#getFirstname().replace(' ', '_')}.
+	 * </p>
+	 *
+	 * If the name does not contain any spaces, returns the regular name.
+	 *
+	 * @return the firstname of the employee, separated by underscores.
+	 */
+	@JsonIgnore
+	public String getFirstnameUnderscoreFormat() {
+		return getFirstname().replace(' ', '_');
+	}
+
+	/**
+	 * Gets the lastname of the employee. <br/>
+	 * This method formats the name, e.g. "Firstname Middlename Lastname", to be
+	 * "Lastname" by splitting the name at the spaces and returning the last String.
+	 * Everything else is interpreted as first- and middle names. <br/>
+	 *
+	 * If the name does not contain any spaces, returns the regular name.
+	 *
+	 * @return the lastname of the employee.
+	 */
+	@JsonIgnore
+	public String getLastname() {
+		if (!getName().contains(" ")) {
+			return getName();
+		}
+		String[] nameParts = getName().split(" ");
+		return nameParts[nameParts.length - 1];
 	}
 }
diff --git a/src/main/java/ui/json/JSONHandler.java b/src/main/java/ui/json/JSONHandler.java
index 336b691..29987ed 100644
--- a/src/main/java/ui/json/JSONHandler.java
+++ b/src/main/java/ui/json/JSONHandler.java
@@ -28,6 +28,39 @@ private JSONHandler() {
 	private static UISettings uiSettings;
 
 	private static String configDir;
+
+	/**
+	 * Default file name format for, as requested by KASTEL @ KIT. This is the same
+	 * as {@link #DEFAULT_PDF_NAME_FORMAT_ALGO} but it connects month and year with
+	 * a space instead of an underscore.
+	 * <p>
+	 * Format Explained: - %LAST%: Last name of the user. - %FIRST_U%: First name of
+	 * the user with underscores instead of spaces. - %MM%: Two-digit month (e.g.,
+	 * 01 for January). - %YYYY%: Four-digit year (e.g., 2025).
+	 * </p>
+	 * <p>
+	 * An example would be:<br/>
+	 * Mustermann_Max_Tobias_04 2025.pdf<br/>
+	 * </p>
+	 */
+	public static final String DEFAULT_PDF_NAME_FORMAT_PROG = "%LAST%_%FIRST_U%_%MM% %YYYY%";
+
+	/**
+	 * Default file name format for, as requested by ITI @ KIT. This is the same as
+	 * {@link #DEFAULT_PDF_NAME_FORMAT_PROG} but it connects month and year with an
+	 * underscore as well.
+	 * <p>
+	 * Format Explained: - %LAST%: Last name of the user. - %FIRST_U%: First name of
+	 * the user with underscores instead of spaces. - %MM%: Two-digit month (e.g.,
+	 * 01 for January). - %YYYY%: Four-digit year (e.g., 2025).
+	 * </p>
+	 * <p>
+	 * An example would be:<br/>
+	 * Mustermann_Max_Tobias_04_2025.pdf<br/>
+	 * </p>
+	 */
+	public static final String DEFAULT_PDF_NAME_FORMAT_ALGO = "%LAST%_%FIRST_U%_%MM%_%YYYY%";
+
 	private static final String CONFIG_FILE_NAME = "global.json";
 	private static final String UI_SETTINGS_FILE_NAME = "settings.json";
 
@@ -260,6 +293,7 @@ public static void createDefaultOtherGlobalSettings() {
 		settings.setUseYYYY(false);
 		settings.setUseGermanMonths(false);
 		settings.setWarnOnHoursMismatch(true);
+		settings.setExportPdfNameFormat(DEFAULT_PDF_NAME_FORMAT_ALGO);
 		saveUISettings(settings);
 	}
 
diff --git a/src/main/java/ui/json/UISettings.java b/src/main/java/ui/json/UISettings.java
index 0e162d2..8c1abf2 100644
--- a/src/main/java/ui/json/UISettings.java
+++ b/src/main/java/ui/json/UISettings.java
@@ -18,6 +18,15 @@ public class UISettings {
 	private String monthPath;
 	private String texPath;
 	private String pdfPath;
+	/**
+	 * Specifies the naming format for exported PDF files.
+	 * <p>
+	 * The format can include placeholders for dynamic values from other current
+	 * settings, such as the name, month and year. The default value is defined by
+	 * {@link JSONHandler#DEFAULT_PDF_NAME_FORMAT_ALGO}.
+	 * </p>
+	 */
+	private String exportPdfNameFormat = JSONHandler.DEFAULT_PDF_NAME_FORMAT_ALGO;
 
 	public UISettings() {
 		// Default Constructor is required
@@ -32,6 +41,7 @@ public UISettings(UISettings uiSettings) {
 		this.monthPath = uiSettings.monthPath;
 		this.texPath = uiSettings.texPath;
 		this.pdfPath = uiSettings.pdfPath;
+		this.exportPdfNameFormat = uiSettings.exportPdfNameFormat;
 	}
 
 	// Constructors, Getters, and Setters


Reviews:
user: login: copilot-pull-request-reviewer[bot]
body: ## Pull Request Overview

Adds support for a user-configurable PDF export filename format with placeholders, updates UI dialogs to set and preview that format, and integrates defaults into JSON settings.

- Introduces `exportPdfNameFormat` field in UI settings with default and presets
- Extends file chooser and name-formatting logic to apply custom placeholders
- Updates settings dialogs (Global and Monthly) with new fields and helper UI

### Reviewed Changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 5 comments.

<details>
<summary>Show a summary per file</summary>

| File                                              | Description                                                  |
| ------------------------------------------------- | ------------------------------------------------------------ |
| src/main/java/ui/json/UISettings.java             | Added `exportPdfNameFormat` field and copy constructor entry |
| src/main/java/ui/json/JSONHandler.java            | Defined default format constants and initialized setting     |
| src/main/java/ui/json/Global.java                 | Refactored name-formatting into `getFirstname`/`getLastname` |
| src/main/java/ui/fileexplorer/FileChooser.java    | Replaced hardcoded format with placeholder-driven logic       |
| src/main/java/ui/UserInterface.java               | Added `getFullYear` accessor                                 |
| src/main/java/ui/TimesheetEntry.java              | Added `isNone` to detect cancelled entries                   |
| src/main/java/ui/MonthlySettingsBar.java          | Added `getFullYearStr`                                       |
| src/main/java/ui/GlobalSettingsDialog.java        | Expanded dialog for PDF format field and help button         |
| src/main/java/ui/DialogHelper.java                | Simplified cancel listener; tweaked placeholder logic        |
</details>



<details>
<summary>Comments suppressed due to low confidence (1)</summary>

**src/main/java/ui/json/Global.java:70**
* [nitpick] The method `getFirstname` actually returns first‐and‐middle names. Consider renaming to `getFirstAndMiddleNames` or clarifying in the Javadoc to avoid confusion.
```
public String getFirstname() {
```
</details>


state: COMMENTED
author_association: NONE
submitted_at: 2025-05-20T14:28:13Z
user: login: Just1Developer
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T14:33:17Z
user: login: Just1Developer
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T14:35:06Z
user: login: Just1Developer
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T14:41:12Z
user: login: robinmaisch
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T16:12:24Z
user: login: Just1Developer
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T16:15:11Z
user: login: Just1Developer
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T16:15:58Z
user: login: robinmaisch
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T11:14:16Z

Review comments:
diff_hunk: @@ -14,12 +16,26 @@ private FileChooser() {
 		// Don't allow instances of this class
 	}
 
-	// Timesheet Format: Nachname_Vorname_Monat Jahr.pdf
-	private static final String FORMAT = "%s_%s 20%s";
-
+	/**
+	 * Returns the default file name for the PDF export based on the users
+	 * settings.
+	 *
+	 * @param parentUI The UserInterface instance to get the current month and year
+	 *                 from.
+	 * @return The default file name for the PDF export.
+	 */
 	public static String getDefaultFileName(UserInterface parentUI) {
-		return FORMAT.formatted(JSONHandler.getGlobalSettings().getNameUnderscoreFormat(), "%02d".formatted(parentUI.getCurrentMonthNumber()),
-				parentUI.getYear());
+		UISettings uiSettings = JSONHandler.getUISettings();
+		Global global = JSONHandler.getGlobalSettings();
+		return uiSettings.getExportPdfNameFormat()
+				.replace("%FIRST_U%", global.getFirstnameUnderscoreFormat())
+				.replace("%FIRST%", global.getFirstname())
+				.replace("%LAST%", global.getLastname())
+				.replace("%MM%", "%02d".formatted(parentUI.getCurrentMonthNumber()))
+				.replace("%MM_GER%", parentUI.getCurrentMonth().getGermanName())
+				.replace("%MM_ENG%", parentUI.getCurrentMonthName())
path: src/main/java/ui/fileexplorer/FileChooser.java
user: login: Copilot
body: Replacing `%MM%` before `%MM_GER%` and `%MM_ENG%` will corrupt those longer placeholders (e.g. `%MM_GER%` becomes `03_GER%`). Move the `%MM_GER%` and `%MM_ENG%` replacements before `%MM%`.
```suggestion
				.replace("%MM_GER%", parentUI.getCurrentMonth().getGermanName())
				.replace("%MM_ENG%", parentUI.getCurrentMonthName())
				.replace("%MM%", "%02d".formatted(parentUI.getCurrentMonthNumber()))
```
created_at: 2025-05-20T14:28:11Z
author_association: NONE
diff_hunk: @@ -40,35 +40,36 @@ public static void showGlobalSettingsDialog(UserInterface parentUI) {
 
 		int row = 0;
 
+		final int TEXTBOXES_COUNT = 6;
+		final int TEXTFIELD_INDEX_PDF_FORMAT = 5;
+
path: src/main/java/ui/GlobalSettingsDialog.java
user: login: Copilot
body: [nitpick] Using a hard-coded index makes the code fragile when fields are reordered. Consider driving this from the labels/placeholders arrays or defining an enum for field indices.
```suggestion

		// Enum for field indices
		enum FieldIndex {
			NAME,
			EMAIL,
			PHONE,
			ADDRESS,
			NOTES,
			PDF_FORMAT
		}
```
created_at: 2025-05-20T14:28:11Z
author_association: NONE
diff_hunk: @@ -367,7 +365,7 @@ public static void addPlaceholderText(JTextComponent component, String placehold
 		component.addFocusListener(new FocusAdapter() {
 			@Override
 			public void focusGained(FocusEvent e) {
-				if (component.getText().equals(placeholder)) {
+				if (component.getForeground().equals(Color.GRAY)) {
path: src/main/java/ui/DialogHelper.java
user: login: Copilot
body: [nitpick] Only checking the text color may clear non-placeholder text if it's gray. It’s safer to check both the placeholder value and the color before clearing.
```suggestion
				if (component.getForeground().equals(Color.GRAY) && component.getText().equals(placeholder)) {
```
created_at: 2025-05-20T14:28:12Z
author_association: NONE
diff_hunk: @@ -28,6 +28,10 @@ private JSONHandler() {
 	private static UISettings uiSettings;
 
 	private static String configDir;
+
+	public static final String DEFAULT_PDF_NAME_FORMAT_PROG = "%LAST%_%FIRST_U%_%MM% %YYYY%";
path: src/main/java/ui/json/JSONHandler.java
user: login: Copilot
body: There’s no comment explaining the placeholder syntax for these new constants. Consider adding Javadoc to document which placeholders are supported and their formats.
```suggestion

	/**
	 * Default PDF name format for programming-related documents.
	 * 
	 * Supported placeholders:
	 * - %LAST%: Last name of the user.
	 * - %FIRST_U%: First initial of the user's first name, in uppercase.
	 * - %MM%: Two-digit month (e.g., 01 for January).
	 * - %YYYY%: Four-digit year (e.g., 2024).
	 */
	public static final String DEFAULT_PDF_NAME_FORMAT_PROG = "%LAST%_%FIRST_U%_%MM% %YYYY%";
	
	/**
	 * Default PDF name format for algorithm-related documents.
	 * 
	 * Supported placeholders:
	 * - %LAST%: Last name of the user.
	 * - %FIRST_U%: First initial of the user's first name, in uppercase.
	 * - %MM%: Two-digit month (e.g., 01 for January).
	 * - %YYYY%: Four-digit year (e.g., 2024).
	 */
```
created_at: 2025-05-20T14:28:12Z
author_association: NONE
diff_hunk: @@ -18,6 +18,7 @@ public class UISettings {
 	private String monthPath;
 	private String texPath;
 	private String pdfPath;
path: src/main/java/ui/json/UISettings.java
user: login: Copilot
body: The new `exportPdfNameFormat` field lacks documentation. A brief Javadoc explaining its purpose and format patterns would improve clarity.
```suggestion
	private String pdfPath;
	
	/**
	 * Specifies the naming format for exported PDF files.
	 * <p>
	 * The format can include placeholders for dynamic values, such as dates or identifiers.
	 * For example, a format like "Report_{YYYY}_{MM}.pdf" might generate file names
	 * like "Report_2024_01.pdf". The default value is defined by
	 * {@link JSONHandler#DEFAULT_PDF_NAME_FORMAT_ALGO}.
	 * </p>
	 */
```
created_at: 2025-05-20T14:28:12Z
author_association: NONE
diff_hunk: @@ -14,12 +16,26 @@ private FileChooser() {
 		// Don't allow instances of this class
 	}
 
-	// Timesheet Format: Nachname_Vorname_Monat Jahr.pdf
-	private static final String FORMAT = "%s_%s 20%s";
-
+	/**
+	 * Returns the default file name for the PDF export based on the users
+	 * settings.
+	 *
+	 * @param parentUI The UserInterface instance to get the current month and year
+	 *                 from.
+	 * @return The default file name for the PDF export.
+	 */
 	public static String getDefaultFileName(UserInterface parentUI) {
-		return FORMAT.formatted(JSONHandler.getGlobalSettings().getNameUnderscoreFormat(), "%02d".formatted(parentUI.getCurrentMonthNumber()),
-				parentUI.getYear());
+		UISettings uiSettings = JSONHandler.getUISettings();
+		Global global = JSONHandler.getGlobalSettings();
+		return uiSettings.getExportPdfNameFormat()
+				.replace("%FIRST_U%", global.getFirstnameUnderscoreFormat())
+				.replace("%FIRST%", global.getFirstname())
+				.replace("%LAST%", global.getLastname())
+				.replace("%MM%", "%02d".formatted(parentUI.getCurrentMonthNumber()))
+				.replace("%MM_GER%", parentUI.getCurrentMonth().getGermanName())
+				.replace("%MM_ENG%", parentUI.getCurrentMonthName())
path: src/main/java/ui/fileexplorer/FileChooser.java
user: login: Just1Developer
body: no? there is a closing % symbol
created_at: 2025-05-20T14:33:16Z
author_association: COLLABORATOR
diff_hunk: @@ -367,7 +365,7 @@ public static void addPlaceholderText(JTextComponent component, String placehold
 		component.addFocusListener(new FocusAdapter() {
 			@Override
 			public void focusGained(FocusEvent e) {
-				if (component.getText().equals(placeholder)) {
+				if (component.getForeground().equals(Color.GRAY)) {
path: src/main/java/ui/DialogHelper.java
user: login: Just1Developer
body: fine
created_at: 2025-05-20T14:35:05Z
author_association: COLLABORATOR
diff_hunk: @@ -18,6 +18,7 @@ public class UISettings {
 	private String monthPath;
 	private String texPath;
 	private String pdfPath;
path: src/main/java/ui/json/UISettings.java
user: login: Just1Developer
body: i dont like this bot it's making me write documentation
created_at: 2025-05-20T14:41:11Z
author_association: COLLABORATOR
diff_hunk: @@ -28,6 +28,10 @@ private JSONHandler() {
 	private static UISettings uiSettings;
 
 	private static String configDir;
+
+	public static final String DEFAULT_PDF_NAME_FORMAT_PROG = "%LAST%_%FIRST_U%_%MM% %YYYY%";
path: src/main/java/ui/json/JSONHandler.java
user: login: robinmaisch
body: Note: The description of %FIRST_U% is inaccurate.
created_at: 2025-05-20T16:12:24Z
author_association: MEMBER
diff_hunk: @@ -28,6 +28,10 @@ private JSONHandler() {
 	private static UISettings uiSettings;
 
 	private static String configDir;
+
+	public static final String DEFAULT_PDF_NAME_FORMAT_PROG = "%LAST%_%FIRST_U%_%MM% %YYYY%";
path: src/main/java/ui/json/JSONHandler.java
user: login: Just1Developer
body: yup. the javadoc I wrote instead should be accurate though
created_at: 2025-05-20T16:15:11Z
author_association: COLLABORATOR
diff_hunk: @@ -40,35 +40,36 @@ public static void showGlobalSettingsDialog(UserInterface parentUI) {
 
 		int row = 0;
 
+		final int TEXTBOXES_COUNT = 6;
+		final int TEXTFIELD_INDEX_PDF_FORMAT = 5;
+
path: src/main/java/ui/GlobalSettingsDialog.java
user: login: Just1Developer
body: is it not... fine the way it is?
created_at: 2025-05-20T16:15:58Z
author_association: COLLABORATOR

PR comments:
user: login: robinmaisch
created_at: 2025-05-20T16:24:52Z
body: The PDF file name field still shows unexpected behavior at times:
If you clear the field and click a preset button, the text appears in grey, resembling a hint, but it is editable.
Until you close the dialog, this field's value then remains in grey.

I don't quite understand why the text should ever be grey. Can you explain? Does it require some magic to show the hint?
author_association: MEMBER
user: login: Just1Developer
created_at: 2025-05-20T17:15:59Z
body: For almost all textfields there are placeholders, these are so that 1) there is always a (valid) non-empty entry, and 2) to display how it should look. Usually, it should turn black when edited (and also when a preset is selected), so I'll go and fix that.
The gray text being editable is consequence of a Copilot suggestion that we shouldn't only look for text color to determine if this is a placeholder. Since here gray textcolor <=> placeholder, I will reverse that change.

The text should only be gray if the field was empty when it was unfocused, as a placeholder is added in that case. Was it gray from the start for you? This should not be the case here...
author_association: COLLABORATOR
user: login: robinmaisch
created_at: 2025-05-20T17:21:40Z
body: No, it was not grey from the start. 
As you describe, the field was empty when unfocused (i.e., I clicked the preset button).

I now realize that only the "Proggen I Preset" is editable, while the "Algo I Preset" is a proper placeholder that disappears when the field is focused again.
author_association: MEMBER
user: login: sonarqubecloud[bot]
created_at: 2025-05-20T17:24:14Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=kit-sdq_TimeSheetGenerator&pullRequest=222) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=kit-sdq_TimeSheetGenerator&pullRequest=222&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=kit-sdq_TimeSheetGenerator&pullRequest=222&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=kit-sdq_TimeSheetGenerator&pullRequest=222&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=kit-sdq_TimeSheetGenerator&pullRequest=222&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=kit-sdq_TimeSheetGenerator&pullRequest=222&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=kit-sdq_TimeSheetGenerator&pullRequest=222)


author_association: NONE
user: login: Just1Developer
created_at: 2025-05-20T17:24:17Z
body: This bug is caused by the additional check. The Algo Preset value is the default, so it recognizes this as placeholder and removes it on focus. I have removed this additional condition.
I chose the algo preset because it has underscores everywhere and looks more uniform, while the proggen preset has a space between month and year for some reason
author_association: COLLABORATOR
user: login: robinmaisch
created_at: 2025-05-20T17:28:20Z
body: LGTM
author_association: MEMBER
user: login: Just1Developer
created_at: 2025-05-21T19:42:23Z
body: Feel free to squash and merge btw, I don't have permissions for it :)
author_association: COLLABORATOR