+ * Der exklusive OS-Lock auf die {@code .properties}-Datei wird über + * {@link FileChannel#tryLock()} mit einer Deadline-Wiederholschleife erworben. + * Solange der Lock gehalten wird, erfolgen Schreibvorgänge direkt über + * den bereits offenen Kanal (Truncate → Position(0) → Write → Force). + * Ohne aktiven Lock werden Schreibvorgänge über eine temporäre Datei + * und {@link Files#move} mit {@code ATOMIC_MOVE} und {@code REPLACE_EXISTING} + * durchgeführt. + *
+ * Beide Ports teilen den internen {@link FileChannel}, damit + * Settings-Schreibvorgänge auch während eines aktiven OS-Locks korrekt + * in die Konfigurationsdatei durchgeschrieben werden können. + *
+ * Instanzen dieser Klasse sind nicht Thread-sicher. Der Aufrufer + * ist für die Serialisierung konkurrierender Zugriffe verantwortlich. + */ +public class FileChannelConfigurationAccessAdapter + implements ConfigurationFileLockPort, SchedulerSettingsPort { + + private static final Logger logger = + LogManager.getLogger(FileChannelConfigurationAccessAdapter.class); + + private static final long ACQUIRE_TIMEOUT_MS = 3000L; + private static final long ACQUIRE_RETRY_INTERVAL_MS = 100L; + + private static final String KEY_ENABLED = "scheduler.enabled"; + private static final String KEY_INTERVAL = "scheduler.interval.seconds"; + + private final Path configFile; + + private FileChannel channel; + private FileLock fileLock; + + /** + * Erstellt einen neuen Adapter für die angegebene Konfigurationsdatei. + * + * @param configFile Pfad zur {@code .properties}-Konfigurationsdatei; + * darf nicht {@code null} sein + */ + public FileChannelConfigurationAccessAdapter(Path configFile) { + this.configFile = Objects.requireNonNull(configFile, "configFile darf nicht null sein"); + } + + // ------------------------------------------------------------------------- + // ConfigurationFileLockPort + // ------------------------------------------------------------------------- + + /** + * Erwirbt den exklusiven OS-Lock auf die Konfigurationsdatei. + *
+ * Ist der Lock bereits durch diese Instanz gehalten, hat dieser Aufruf + * keine Wirkung (idempotent). Andernfalls wird der {@link FileChannel} + * mit {@link StandardOpenOption#READ} und {@link StandardOpenOption#WRITE} + * geöffnet und {@link FileChannel#tryLock()} in einer Schleife mit + * {@value ACQUIRE_RETRY_INTERVAL_MS}-ms-Pausen versucht. Schlägt der + * Erwerb innerhalb von {@value ACQUIRE_TIMEOUT_MS} ms fehl, werden + * Kanal und Lock geschlossen und eine {@link ConfigurationFileLockException} + * geworfen. + * + * @throws ConfigurationFileLockException wenn der Lock nicht innerhalb der + * Deadline erworben werden kann, ein I/O-Fehler auftritt oder der + * Thread unterbrochen wird + */ + @Override + public void acquireLock() throws ConfigurationFileLockException { + if (isLocked()) { + return; + } + long deadline = System.currentTimeMillis() + ACQUIRE_TIMEOUT_MS; + try { + channel = FileChannel.open(configFile, + StandardOpenOption.READ, StandardOpenOption.WRITE); + while (true) { + try { + FileLock lock = channel.tryLock(); + if (lock != null) { + this.fileLock = lock; + logger.debug("OS-Lock auf Konfigurationsdatei erworben: {}", configFile); + return; + } + } catch (OverlappingFileLockException e) { + // Dieselbe JVM hält bereits einen Lock auf diesen Dateibereich; + // wird wie ein nicht verfügbarer Lock behandelt. + } + if (System.currentTimeMillis() >= deadline) { + closeChannelSilently(); + throw new ConfigurationFileLockException( + "Konfigurationsdatei konnte nicht gesperrt werden: " + + "Timeout nach " + ACQUIRE_TIMEOUT_MS + " ms. Datei: " + configFile); + } + Thread.sleep(ACQUIRE_RETRY_INTERVAL_MS); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + closeChannelSilently(); + throw new ConfigurationFileLockException( + "Lock-Erwerb auf Konfigurationsdatei wurde unterbrochen.", e); + } catch (IOException e) { + closeChannelSilently(); + throw new ConfigurationFileLockException( + "Konfigurationsdatei konnte nicht geöffnet oder gesperrt werden: " + + configFile, e); + } + } + + /** + * Gibt den exklusiven Lock frei und schließt den {@link FileChannel}. + *
+ * Ist kein Lock aktiv, hat dieser Aufruf keine Wirkung (idempotent). + * Aufgetretene I/O-Fehler werden geloggt und still übergangen. + */ + @Override + public void releaseLock() { + if (fileLock != null) { + try { + fileLock.release(); + logger.debug("OS-Lock auf Konfigurationsdatei freigegeben: {}", configFile); + } catch (IOException e) { + logger.warn("Fehler beim Freigeben des FileLock für {}.", configFile, e); + } + fileLock = null; + } + closeChannelSilently(); + } + + /** + * Prüft, ob der Lock aktuell von dieser Instanz gehalten wird. + * + * @return {@code true}, wenn der Lock aktiv und gültig ist + */ + @Override + public boolean isLocked() { + return fileLock != null && fileLock.isValid(); + } + + // ------------------------------------------------------------------------- + // SchedulerSettingsPort + // ------------------------------------------------------------------------- + + /** + * Liest die aktuellen Scheduler-Einstellungen aus der Konfigurationsdatei. + *
+ * Fehlt ein Key oder ist er leer, wird der jeweilige Standardwert aus + * {@link SchedulerSettings#defaults()} zurückgegeben. Ungültige Werte + * (z.B. nicht-numerisches Intervall) führen ebenfalls zu den Standardwerten, + * nicht zu einer Exception. + * + * @return aktuelle Scheduler-Einstellungen; nie {@code null} + */ + @Override + public SchedulerSettings loadSettings() { + Properties props = new Properties(); + try { + String content = Files.readString(configFile, StandardCharsets.UTF_8); + props.load(new StringReader(content)); + } catch (IOException e) { + logger.warn("Scheduler-Einstellungen konnten nicht geladen werden, " + + "Standardwerte werden verwendet. Datei: {}", configFile, e); + return SchedulerSettings.defaults(); + } + boolean enabled = parseEnabled(props.getProperty(KEY_ENABLED)); + int intervalSeconds = parseInterval(props.getProperty(KEY_INTERVAL)); + return new SchedulerSettings(enabled, intervalSeconds); + } + + /** + * Schreibt den Wert von {@code scheduler.enabled} in die Konfigurationsdatei. + *
+ * Alle übrigen Inhalte der Datei bleiben unverändert. Existiert der Key + * noch nicht, wird er am Ende der Datei ergänzt. + * + * @param enabled neuer Wert für {@code scheduler.enabled} + * @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt + */ + @Override + public void saveEnabled(boolean enabled) throws SchedulerSettingsWriteException { + updateProperty(KEY_ENABLED, String.valueOf(enabled)); + } + + /** + * Schreibt den Wert von {@code scheduler.interval.seconds} in die + * Konfigurationsdatei. + *
+ * Alle übrigen Inhalte der Datei bleiben unverändert. Existiert der Key
+ * noch nicht, wird er am Ende der Datei ergänzt.
+ *
+ * @param seconds neues Intervall in Sekunden
+ * @throws SchedulerSettingsWriteException wenn der Schreibvorgang fehlschlägt
+ */
+ @Override
+ public void saveIntervalSeconds(int seconds) throws SchedulerSettingsWriteException {
+ updateProperty(KEY_INTERVAL, String.valueOf(seconds));
+ }
+
+ // -------------------------------------------------------------------------
+ // Hilfsmethoden: Parsen
+ // -------------------------------------------------------------------------
+
+ private boolean parseEnabled(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return SchedulerSettings.DEFAULT_ENABLED;
+ }
+ return Boolean.parseBoolean(raw.trim());
+ }
+
+ private int parseInterval(String raw) {
+ if (raw == null || raw.isBlank()) {
+ return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
+ }
+ try {
+ return Integer.parseInt(raw.trim());
+ } catch (NumberFormatException e) {
+ return SchedulerSettings.DEFAULT_INTERVAL_SECONDS;
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // Hilfsmethoden: format-erhaltende Schreiblogik
+ // -------------------------------------------------------------------------
+
+ private void updateProperty(String key, String value) throws SchedulerSettingsWriteException {
+ try {
+ byte[] rawBytes = isLocked() ? readAllBytesViaChannel() : Files.readAllBytes(configFile);
+ String separator = detectLineSeparator(rawBytes);
+ String rawContent = new String(rawBytes, StandardCharsets.UTF_8);
+ List
+ * Ist der OS-Lock aktiv, wird über den gemeinsamen {@link FileChannel}
+ * geschrieben (Truncate → Position(0) → Write → Force). Ist kein Lock aktiv,
+ * wird eine temporäre Datei erzeugt und danach atomar verschoben.
+ */
+ private void writeContent(String content) throws IOException {
+ byte[] bytes = content.getBytes(StandardCharsets.UTF_8);
+ if (isLocked()) {
+ channel.truncate(0);
+ channel.position(0);
+ ByteBuffer buffer = ByteBuffer.wrap(bytes);
+ while (buffer.hasRemaining()) {
+ channel.write(buffer);
+ }
+ channel.force(true);
+ } else {
+ Path tempFile = configFile.resolveSibling(configFile.getFileName() + ".tmp");
+ Files.writeString(tempFile, content, StandardCharsets.UTF_8,
+ StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+ Files.move(tempFile, configFile,
+ StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ private void closeChannelSilently() {
+ if (channel != null) {
+ try {
+ channel.close();
+ } catch (IOException e) {
+ logger.warn("Fehler beim Schließen des FileChannel für {}.", configFile, e);
+ }
+ channel = null;
+ }
+ }
+}
diff --git a/pdf-umbenenner-adapter-in-scheduler/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapterTest.java b/pdf-umbenenner-adapter-in-scheduler/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapterTest.java
new file mode 100644
index 0000000..3965c26
--- /dev/null
+++ b/pdf-umbenenner-adapter-in-scheduler/src/test/java/de/gecheckt/pdf/umbenenner/adapter/in/scheduler/FileChannelConfigurationAccessAdapterTest.java
@@ -0,0 +1,336 @@
+package de.gecheckt.pdf.umbenenner.adapter.in.scheduler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.io.StringReader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationFileLockException;
+import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettings;
+import de.gecheckt.pdf.umbenenner.application.port.out.SchedulerSettingsWriteException;
+
+/**
+ * Unit-Tests für {@link FileChannelConfigurationAccessAdapter}.
+ *
+ * Deckt das Lock-Protokoll (Erwerb, Freigabe, Idempotenz), die Settings-Lese-
+ * und Schreiblogik sowie die format-erhaltenden Zeileneigenschaften ab.
+ * Alle Tests arbeiten auf einer temporären {@code .properties}-Datei im
+ * JUnit-eigenen {@code @TempDir}.
+ */
+class FileChannelConfigurationAccessAdapterTest {
+
+ // =========================================================================
+ // Lock-Protokoll
+ // =========================================================================
+
+ @Test
+ void isLocked_returnsFalseBeforeAnyAcquire(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ assertThat(adapter.isLocked()).isFalse();
+ }
+
+ @Test
+ void acquireLock_setsIsLockedTrue(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.acquireLock();
+ try {
+ assertThat(adapter.isLocked()).isTrue();
+ } finally {
+ adapter.releaseLock();
+ }
+ }
+
+ @Test
+ void releaseLock_setsIsLockedFalse(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.acquireLock();
+ adapter.releaseLock();
+
+ assertThat(adapter.isLocked()).isFalse();
+ }
+
+ @Test
+ void acquireLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.acquireLock();
+ try {
+ assertThatCode(adapter::acquireLock).doesNotThrowAnyException();
+ assertThat(adapter.isLocked()).isTrue();
+ } finally {
+ adapter.releaseLock();
+ }
+ }
+
+ @Test
+ void releaseLock_calledTwice_isIdempotent(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.acquireLock();
+ adapter.releaseLock();
+
+ assertThatCode(adapter::releaseLock).doesNotThrowAnyException();
+ assertThat(adapter.isLocked()).isFalse();
+ }
+
+ @Test
+ void releaseLock_withoutPriorAcquire_doesNotThrow(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ assertThatCode(adapter::releaseLock).doesNotThrowAnyException();
+ }
+
+ @Test
+ void acquireLock_throwsConfigurationFileLockException_whenFileDoesNotExist(
+ @TempDir Path tempDir) {
+ Path nonExistent = tempDir.resolve("missing.properties");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(nonExistent);
+
+ assertThatThrownBy(adapter::acquireLock)
+ .isInstanceOf(ConfigurationFileLockException.class);
+ }
+
+ // =========================================================================
+ // loadSettings
+ // =========================================================================
+
+ @Test
+ void loadSettings_returnsDefaultsWhenKeysAreMissing(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "source.folder=S:\\source\n");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ SchedulerSettings settings = adapter.loadSettings();
+
+ assertThat(settings.enabled()).isEqualTo(SchedulerSettings.DEFAULT_ENABLED);
+ assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS);
+ }
+
+ @Test
+ void loadSettings_returnsConfiguredValues(@TempDir Path tempDir) throws IOException {
+ String content = "scheduler.enabled=true\nscheduler.interval.seconds=300\n";
+ Path config = createConfigFile(tempDir, content);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ SchedulerSettings settings = adapter.loadSettings();
+
+ assertThat(settings.enabled()).isTrue();
+ assertThat(settings.intervalSeconds()).isEqualTo(300);
+ }
+
+ @Test
+ void loadSettings_returnsDefaultIntervalForNonNumericValue(@TempDir Path tempDir)
+ throws IOException {
+ String content = "scheduler.enabled=false\nscheduler.interval.seconds=not-a-number\n";
+ Path config = createConfigFile(tempDir, content);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ SchedulerSettings settings = adapter.loadSettings();
+
+ assertThat(settings.intervalSeconds()).isEqualTo(SchedulerSettings.DEFAULT_INTERVAL_SECONDS);
+ }
+
+ @Test
+ void loadSettings_returnsDefaultEnabledForBlankValue(@TempDir Path tempDir)
+ throws IOException {
+ String content = "scheduler.enabled=\nscheduler.interval.seconds=180\n";
+ Path config = createConfigFile(tempDir, content);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ SchedulerSettings settings = adapter.loadSettings();
+
+ assertThat(settings.enabled()).isEqualTo(SchedulerSettings.DEFAULT_ENABLED);
+ }
+
+ @Test
+ void loadSettings_returnsDefaultsWhenFileIsEmpty(@TempDir Path tempDir) throws IOException {
+ Path config = createConfigFile(tempDir, "");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ SchedulerSettings settings = adapter.loadSettings();
+
+ assertThat(settings).isEqualTo(SchedulerSettings.defaults());
+ }
+
+ // =========================================================================
+ // saveEnabled
+ // =========================================================================
+
+ @Test
+ void saveEnabled_updatesExistingKeyAndPreservesOtherLines(@TempDir Path tempDir)
+ throws IOException {
+ String initial = "source.folder=/opt/source\nscheduler.enabled=false\ntarget.folder=/opt/target\n";
+ Path config = createConfigFile(tempDir, initial);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.saveEnabled(true);
+
+ Properties props = loadProperties(config);
+ assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true");
+ assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source");
+ assertThat(props.getProperty("target.folder")).isEqualTo("/opt/target");
+ }
+
+ @Test
+ void saveEnabled_appendsKeyWhenMissing(@TempDir Path tempDir) throws IOException {
+ String initial = "source.folder=/opt/source\n";
+ Path config = createConfigFile(tempDir, initial);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.saveEnabled(true);
+
+ Properties props = loadProperties(config);
+ assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true");
+ assertThat(props.getProperty("source.folder")).isEqualTo("/opt/source");
+ }
+
+ @Test
+ void saveEnabled_writesCorrectlyThroughChannelWhenLocked(@TempDir Path tempDir)
+ throws IOException {
+ String initial = "scheduler.enabled=false\n";
+ Path config = createConfigFile(tempDir, initial);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.acquireLock();
+ try {
+ adapter.saveEnabled(true);
+ } finally {
+ adapter.releaseLock();
+ }
+
+ Properties props = loadProperties(config);
+ assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true");
+ }
+
+ @Test
+ void saveEnabled_throwsSchedulerSettingsWriteException_whenFileDoesNotExist(
+ @TempDir Path tempDir) {
+ Path nonExistent = tempDir.resolve("missing.properties");
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(nonExistent);
+
+ assertThatThrownBy(() -> adapter.saveEnabled(true))
+ .isInstanceOf(SchedulerSettingsWriteException.class);
+ }
+
+ // =========================================================================
+ // saveIntervalSeconds
+ // =========================================================================
+
+ @Test
+ void saveIntervalSeconds_updatesExistingKeyAndPreservesOtherLines(@TempDir Path tempDir)
+ throws IOException {
+ String initial = "scheduler.interval.seconds=180\nscheduler.enabled=false\n";
+ Path config = createConfigFile(tempDir, initial);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.saveIntervalSeconds(300);
+
+ Properties props = loadProperties(config);
+ assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("300");
+ assertThat(props.getProperty("scheduler.enabled")).isEqualTo("false");
+ }
+
+ @Test
+ void saveIntervalSeconds_appendsKeyWhenMissing(@TempDir Path tempDir) throws IOException {
+ String initial = "scheduler.enabled=true\n";
+ Path config = createConfigFile(tempDir, initial);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.saveIntervalSeconds(240);
+
+ Properties props = loadProperties(config);
+ assertThat(props.getProperty("scheduler.interval.seconds")).isEqualTo("240");
+ assertThat(props.getProperty("scheduler.enabled")).isEqualTo("true");
+ }
+
+ // =========================================================================
+ // Zeilenenden-Erhaltung
+ // =========================================================================
+
+ @Test
+ void saveEnabled_preservesCrlfLineEndings(@TempDir Path tempDir) throws IOException {
+ String initial = "scheduler.enabled=false\r\nother.key=value\r\n";
+ Path config = createConfigFileBinary(tempDir, initial.getBytes(StandardCharsets.UTF_8));
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.saveEnabled(true);
+
+ byte[] resultBytes = Files.readAllBytes(config);
+ String result = new String(resultBytes, StandardCharsets.UTF_8);
+ assertThat(result).contains("scheduler.enabled=true\r\n");
+ assertThat(result).contains("other.key=value\r\n");
+ }
+
+ @Test
+ void saveEnabled_preservesLfLineEndings(@TempDir Path tempDir) throws IOException {
+ String initial = "scheduler.enabled=false\nother.key=value\n";
+ Path config = createConfigFile(tempDir, initial);
+ FileChannelConfigurationAccessAdapter adapter =
+ new FileChannelConfigurationAccessAdapter(config);
+
+ adapter.saveEnabled(true);
+
+ String result = Files.readString(config, StandardCharsets.UTF_8);
+ assertThat(result).contains("scheduler.enabled=true\n");
+ assertThat(result).contains("other.key=value\n");
+ assertThat(result).doesNotContain("\r\n");
+ }
+
+ // =========================================================================
+ // Hilfsmethoden
+ // =========================================================================
+
+ private static Path createConfigFile(Path tempDir, String content) throws IOException {
+ Path config = tempDir.resolve("test.properties");
+ Files.writeString(config, content, StandardCharsets.UTF_8);
+ return config;
+ }
+
+ private static Path createConfigFileBinary(Path tempDir, byte[] bytes) throws IOException {
+ Path config = tempDir.resolve("test.properties");
+ Files.write(config, bytes);
+ return config;
+ }
+
+ private static Properties loadProperties(Path file) throws IOException {
+ Properties props = new Properties();
+ props.load(new StringReader(Files.readString(file, StandardCharsets.UTF_8)));
+ return props;
+ }
+}