diff --git a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java index 2b916a6..518cc10 100644 --- a/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java +++ b/pdf-umbenenner-adapter-in-gui/src/main/java/de/gecheckt/pdf/umbenenner/adapter/in/gui/GuiConfigurationEditorWorkspace.java @@ -459,13 +459,19 @@ public final class GuiConfigurationEditorWorkspace { private final GuiBatchRunTab batchRunTab; /** - * Dritter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion + * Dritter Haupt-Tab: Scheduler-Steuerung. Wird während der Workspace-Konstruktion + * erstellt und in den {@link #tabPane} eingehängt. + */ + private final GuiSchedulerTab schedulerTab; + + /** + * Vierter Haupt-Tab: Historien-Tab „Verlauf". Wird während der Workspace-Konstruktion * erstellt und in den {@link #tabPane} eingehängt. */ private final de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab historyTab; /** - * Vierter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt + * Fünfter Haupt-Tab: Prompt-Editor. Wird während der Workspace-Konstruktion erstellt * und in den {@link #tabPane} eingehängt. */ private final GuiPromptEditorTab promptEditorTab; @@ -557,6 +563,10 @@ public final class GuiConfigurationEditorWorkspace { this::editorTargetFolder, effectiveContext.configurationFileLockPort()); + this.schedulerTab = new GuiSchedulerTab( + effectiveContext.schedulerControlUseCase(), + () -> editorState.isDirty()); + this.historyTab = new de.gecheckt.pdf.umbenenner.adapter.in.gui.history.GuiHistoryTab( effectiveContext.historyOverviewPort(), effectiveContext.historyDetailsPort(), @@ -1091,14 +1101,15 @@ public final class GuiConfigurationEditorWorkspace { *
+ * Zeigt den aktuellen Zustand des automatischen Schedulers und erlaubt dessen + * Steuerung über {@link SchedulerControlUseCase}. Der Tab-Inhalt wird im Sekundentakt + * durch {@link #updateStatus(SchedulerStatus)} aktualisiert, das von der zentralen + * {@link GuiStatusRefreshTimeline} aufgerufen wird. + * + *
Alle öffentlichen Methoden müssen auf dem JavaFX Application Thread aufgerufen
+ * werden. Start-, Stopp- und Speichern-Aktionen werden auf einem dedizierten
+ * Hintergrund-Worker-Thread ({@code gui-scheduler-control}) ausgeführt.
+ */
+public final class GuiSchedulerTab {
+
+ private static final Logger LOG = LogManager.getLogger(GuiSchedulerTab.class);
+
+ private static final String TAB_TITLE = "Scheduler";
+
+ /** Mindestwert für das konfigurierbare Ausführungsintervall. */
+ static final int MIN_INTERVAL_SECONDS = 30;
+
+ private static final DateTimeFormatter TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("HH:mm").withZone(ZoneId.systemDefault());
+
+ private final Tab tab = new Tab(TAB_TITLE);
+ private final Optional
+ * Wird von der {@link GuiStatusRefreshTimeline} im Sekundentakt auf dem
+ * JavaFX Application Thread aufgerufen. Implementiert alle in der Spezifikation
+ * definierten Button-Zustände, Label-Texte und Sichtbarkeitsregeln.
+ *
+ * @param status aktueller Scheduler-Status; darf nicht {@code null} sein
+ */
+ public void updateStatus(SchedulerStatus status) {
+ updateStatusLabel(status);
+ updateButtons(status);
+ updateNextTickLabel(status);
+ updateLastRunLabel(status);
+ updateLastErrorLabel(status);
+ updateIntervalFieldEditability(status);
+ updateAutostartBanner(status);
+ }
+
+ // -------------------------------------------------------------------------
+ // UI-Aufbau
+ // -------------------------------------------------------------------------
+
+ private void buildUi() {
+ buildAutostartBanner();
+ VBox controlArea = buildControlArea();
+ VBox content = new VBox(0, autostartErrorBanner, controlArea);
+ tab.setContent(content);
+ wireActions();
+ }
+
+ private void buildAutostartBanner() {
+ Label autostartTitleLabel = new Label("⚠ Autostart fehlgeschlagen – Scheduler ist nicht aktiv.");
+ autostartTitleLabel.setStyle("-fx-font-weight: bold;");
+ autostartErrorDetailLabel.setWrapText(true);
+ HBox bannerButtons = new HBox(10, autostartStartButton, autostartDisableButton);
+ autostartErrorBanner.getChildren().addAll(
+ autostartTitleLabel, autostartErrorDetailLabel, bannerButtons);
+ autostartErrorBanner.setStyle(
+ "-fx-background-color: #fff3cd; -fx-border-color: #ffc107;"
+ + " -fx-border-width: 1; -fx-padding: 10;");
+ autostartErrorBanner.setVisible(false);
+ autostartErrorBanner.setManaged(false);
+ }
+
+ private VBox buildControlArea() {
+ statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #7f8c8d;");
+
+ stopButton.setDisable(true);
+ HBox buttonBox = new HBox(10, startButton, stopButton);
+
+ nextTickLabel.setVisible(false);
+ nextTickLabel.setManaged(false);
+
+ lastRunLabel.setWrapText(true);
+
+ lastErrorLabel.setStyle("-fx-text-fill: #c0392b;");
+ lastErrorLabel.setWrapText(true);
+ lastErrorLabel.setVisible(false);
+ lastErrorLabel.setManaged(false);
+
+ Label intervalLabel = new Label("Intervall (Sekunden):");
+ intervalField.setPrefColumnCount(10);
+ HBox intervalBox = new HBox(10, intervalLabel, intervalField);
+ intervalBox.setAlignment(Pos.CENTER_LEFT);
+
+ intervalValidationLabel.setStyle("-fx-text-fill: #c0392b; -fx-font-size: 11px;");
+ intervalValidationLabel.setWrapText(true);
+ intervalValidationLabel.setVisible(false);
+ intervalValidationLabel.setManaged(false);
+
+ VBox controlArea = new VBox(12,
+ statusLabel,
+ buttonBox,
+ nextTickLabel,
+ lastRunLabel,
+ lastErrorLabel,
+ new Separator(),
+ intervalBox,
+ intervalValidationLabel);
+ controlArea.setPadding(new Insets(16));
+ return controlArea;
+ }
+
+ private void wireActions() {
+ startButton.setOnAction(e -> executeStart());
+ stopButton.setOnAction(e -> executeStop());
+ autostartStartButton.setOnAction(e -> executeStart());
+ autostartDisableButton.setOnAction(e -> executeDisableAutostart());
+
+ intervalField.focusedProperty().addListener((obs, wasFocused, focused) -> {
+ if (!focused) {
+ validateAndSaveInterval();
+ }
+ });
+ }
+
+ private void applyInitialState() {
+ if (schedulerUseCase.isEmpty()) {
+ startButton.setDisable(true);
+ startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
+ stopButton.setDisable(true);
+ intervalField.setEditable(false);
+ intervalField.setDisable(true);
+ } else {
+ intervalField.setText(String.valueOf(schedulerUseCase.get().getIntervalSeconds()));
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // updateStatus-Hilfsmethoden
+ // -------------------------------------------------------------------------
+
+ private void updateStatusLabel(SchedulerStatus status) {
+ switch (status.state()) {
+ case STOPPED -> {
+ statusLabel.setText("○ Gestoppt");
+ statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #7f8c8d;");
+ }
+ case STARTING -> {
+ statusLabel.setText("⟳ Wird gestartet…");
+ statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #e67e22;");
+ }
+ case RUNNING_IDLE -> {
+ statusLabel.setText("● Aktiv");
+ statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
+ }
+ case RUNNING_BATCH_ACTIVE -> {
+ statusLabel.setText("● Aktiv – Lauf aktiv");
+ statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #27ae60;");
+ }
+ case STOPPING_BATCH_ACTIVE -> {
+ statusLabel.setText("○ Gestoppt – aktueller Lauf läuft noch");
+ statusLabel.setStyle("-fx-font-size: 14px; -fx-font-weight: bold; -fx-text-fill: #7f8c8d;");
+ }
+ }
+ }
+
+ private void updateButtons(SchedulerStatus status) {
+ boolean noUseCase = schedulerUseCase.isEmpty();
+ boolean configDirty = Boolean.TRUE.equals(isConfigDirty.get());
+
+ switch (status.state()) {
+ case STOPPED -> {
+ stopButton.setDisable(true);
+ if (noUseCase) {
+ startButton.setDisable(true);
+ startButton.setTooltip(new Tooltip("Anwendung nicht laufbereit"));
+ } else if (configDirty) {
+ startButton.setDisable(true);
+ startButton.setTooltip(new Tooltip("Bitte Konfiguration speichern"));
+ } else {
+ startButton.setDisable(false);
+ startButton.setTooltip(null);
+ }
+ }
+ case STARTING -> {
+ startButton.setDisable(true);
+ stopButton.setDisable(true);
+ }
+ case RUNNING_IDLE, RUNNING_BATCH_ACTIVE -> {
+ startButton.setDisable(true);
+ startButton.setTooltip(null);
+ stopButton.setDisable(false);
+ }
+ case STOPPING_BATCH_ACTIVE -> {
+ startButton.setDisable(true);
+ stopButton.setDisable(true);
+ }
+ }
+ autostartStartButton.setDisable(startButton.isDisable());
+ }
+
+ private void updateNextTickLabel(SchedulerStatus status) {
+ if (status.state() == SchedulerState.RUNNING_IDLE && status.nextTickAt().isPresent()) {
+ long remaining = ChronoUnit.SECONDS.between(Instant.now(), status.nextTickAt().get());
+ if (remaining > 0) {
+ long minutes = remaining / 60;
+ long seconds = remaining % 60;
+ nextTickLabel.setText(String.format("Nächster Lauf in: %02d:%02d", minutes, seconds));
+ } else {
+ nextTickLabel.setText("Lauf steht bevor…");
+ }
+ nextTickLabel.setVisible(true);
+ nextTickLabel.setManaged(true);
+ } else {
+ nextTickLabel.setVisible(false);
+ nextTickLabel.setManaged(false);
+ }
+ }
+
+ private void updateLastRunLabel(SchedulerStatus status) {
+ if (status.lastRunEndedAt().isPresent() && status.lastRunSummary().isPresent()) {
+ Instant endedAt = status.lastRunEndedAt().get();
+ RunSummary summary = status.lastRunSummary().get();
+ String timeStr = TIME_FORMATTER.format(endedAt);
+ boolean noDocuments = summary.successCount() == 0
+ && summary.failedCount() == 0
+ && summary.skippedCount() == 0;
+ if (noDocuments) {
+ lastRunLabel.setText("Letzter Lauf: " + timeStr + " – keine neuen Dokumente");
+ } else {
+ lastRunLabel.setText("Letzter Lauf: " + timeStr + " – "
+ + summary.successCount() + " Dokumente verarbeitet");
+ }
+ } else {
+ lastRunLabel.setText("Noch kein Lauf in dieser Sitzung.");
+ }
+ }
+
+ private void updateLastErrorLabel(SchedulerStatus status) {
+ Optional
+ * Wird vom Scheduler-Tab genutzt, um den Initialwert des Intervall-Feldes
+ * anzuzeigen. Der Wert entspricht dem beim Start der Anwendung geladenen
+ * Konfigurationswert (mindestens 30 Sekunden).
+ *
+ * @return Intervall in Sekunden; immer ≥ 30
+ */
+ int getIntervalSeconds();
+
+ /**
+ * Persistiert das Ausführungsintervall in die Konfigurationsdatei.
+ *
+ * Sicher nur aufzurufen wenn der Scheduler gestoppt ist. Der in-Memory-Wert
+ * wird nicht aktualisiert; der neue Wert wird beim nächsten Anwendungsstart
+ * gelesen.
+ *
+ * Muss auf einem Hintergrund-Thread aufgerufen werden, da der Schreibvorgang
+ * den Konfigurations-Datei-Lock erwerben muss.
+ *
+ * @param seconds Intervall in Sekunden; sollte ≥ 30 sein
+ */
+ void saveIntervalSeconds(int seconds);
+
+ /**
+ * Deaktiviert den Autostart durch Persistieren von {@code scheduler.enabled=false}.
+ *
+ * Wird vom Scheduler-Tab aufgerufen, wenn der Benutzer den fehlgeschlagenen
+ * Autostart dauerhaft deaktivieren möchte. Sicher aufzurufen wenn der Scheduler
+ * gestoppt ist.
+ *
+ * Muss auf einem Hintergrund-Thread aufgerufen werden.
+ */
+ void disableAutostart();
}
diff --git a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java
index 871f6be..0d279a3 100644
--- a/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java
+++ b/pdf-umbenenner-application/src/main/java/de/gecheckt/pdf/umbenenner/application/usecase/DefaultSchedulerControlUseCase.java
@@ -197,6 +197,42 @@ public class DefaultSchedulerControlUseCase implements SchedulerControlUseCase {
return statusRef.get();
}
+ /**
+ * Gibt das beim Start der Anwendung geladene Ausführungsintervall in Sekunden zurück.
+ *
+ * @return Intervall in Sekunden; immer ≥ 30
+ */
+ @Override
+ public int getIntervalSeconds() {
+ return intervalSeconds;
+ }
+
+ /**
+ * Persistiert das Ausführungsintervall in die Konfigurationsdatei.
+ *
+ * Der in-Memory-Wert wird nicht aktualisiert; der neue Wert wird beim
+ * nächsten Anwendungsstart gelesen.
+ *
+ * @param seconds Intervall in Sekunden
+ */
+ @Override
+ public void saveIntervalSeconds(int seconds) {
+ settingsPort.saveIntervalSeconds(seconds);
+ }
+
+ /**
+ * Deaktiviert den Autostart durch Persistieren von {@code scheduler.enabled=false}.
+ */
+ @Override
+ public void disableAutostart() {
+ try {
+ settingsPort.saveEnabled(false);
+ logger.info("Autostart deaktiviert.");
+ } catch (Exception e) {
+ logger.warn("Fehler beim Deaktivieren des Autostarts.", e);
+ }
+ }
+
/**
* Markiert den Autostart als fehlgeschlagen.
*