View Javadoc
1   package de.spiritscorp.datasync.controller;
2   
3   /*-
4    * 		Data Sync
5    *
6    * 		Copyright ©   2022    The Spirit
7    * 		@email                        thespirit@spiritscorp.network
8    *
9    * 		This program is free software; you can redistribute it and/or modify
10   * 		it under the terms of the GNU General Public License as published by
11   * 		the Free Software Foundation; either version 3 of the License, or
12   * 		(at your option) any later version.
13   *
14   * 		This program is distributed in the hope that it will be useful,
15   * 		but WITHOUT ANY WARRANTY; without even the implied warranty of
16   * 		MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
17   * 		See the GNU General Public License for more details.
18   *
19   * 		You should have received a copy of the GNU General Public License
20   * 		along with this program. If not, see <http://www.gnu.org/licenses/>.
21   */
22  
23  import java.io.BufferedWriter;
24  import java.io.IOException;
25  import java.io.OutputStreamWriter;
26  import java.nio.charset.StandardCharsets;
27  import java.nio.file.Files;
28  import java.nio.file.Path;
29  import java.nio.file.Paths;
30  import java.util.ArrayList;
31  import java.util.List;
32  import java.util.Locale;
33  import java.util.Map;
34  
35  import javafx.application.Platform;
36  
37  import de.spiritscorp.datasync.CLIFlags;
38  import de.spiritscorp.datasync.Main;
39  import de.spiritscorp.datasync.gui.DialogService;
40  import de.spiritscorp.datasync.io.Debug;
41  import de.spiritscorp.datasync.io.Logger;
42  import de.spiritscorp.datasync.io.Preference;
43  import de.spiritscorp.datasync.io.PreferenceManager;
44  import de.spiritscorp.datasync.model.FileAttributes;
45  import de.spiritscorp.datasync.model.Model;
46  
47  /**
48   * Orchestrates background thread processing for synchronization, backup,
49   * and duplicate detection, utilizing task-isolated configuration states.
50   *
51   * @author Tom Spirit
52   */
53  public class SyncJobService {
54  
55  	/**
56  	 * Service used to display modal dialogs and confirmation prompts to the user.
57  	 */
58  	private final DialogService dialogService;
59  
60  	/**
61  	 * Formatter utility responsible for converting raw sync metrics into human-readable UI logs.
62  	 */
63  	private final LogFormatter logFormatter;
64  
65  	/**
66  	 * Constructs a new {@code SyncJobService} with the required UI and logging dependencies.
67  	 *
68  	 * @param dialogService the service provider for user interaction dialogs
69  	 * @param logFormatter  the formatter instance used to compile text summaries for the UI
70  	 */
71  	public SyncJobService( final DialogService dialogService, final LogFormatter logFormatter ) {
72  		this.dialogService = dialogService;
73  		this.logFormatter = logFormatter;
74  	}
75  
76  	/**
77  	 * Executes bidirectional folder synchronization using task-bound preferences.
78  	 *
79  	 * @param context Target environment details providing task variables
80  	 */
81  	public void startSynchronize( final SyncJobContext context ) {
82  		if( context.isRunning() ) return;
83  
84  		context.setRunning( true );
85  		context.setStatusMessage( "Synchronisation gestartet. Scanne Verzeichnisse..." );
86  		context.clearLog();
87  
88  		final Map<Path, FileAttributes> sourceMap = Model.createMap();
89  		final Map<Path, FileAttributes> destMap = Model.createMap();
90  		final Map<Path, FileAttributes> failMap = Model.createMap();
91  		final Preference pref = context.getPreference();
92  		final Model model = new Model( new Logger(), sourceMap, destMap );
93  		final Long[] stats = new Long[4];
94  
95  		final Thread worker = new Thread( () -> {
96  			long startTime = System.nanoTime();
97  			try {
98  				final Path startDestPath = pref.getDestPaths().get( 0 );
99  				final Path startSourcePath = pref.getSourcePaths().get( 0 );
100 
101 				if( startDestPath == null || !Files.exists( startDestPath ) ) {
102 					updateUIStatus( context, false, "Kein Ziellaufwerk vorhanden" );
103 					return;
104 				}
105 
106 				if( pref.getSourcePaths().size() > 1 ) {
107 					updateUIStatus( context, false, "Die Synchronisierung funktioniert nur mit einem Quellordner!" );
108 					return;
109 				}
110 
111 				failMap.putAll( model.scanSyncFiles( pref.getSourcePaths(), pref.getDestPaths(), stats, pref.getScanMode(), false, false ) );
112 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
113 
114 				final ArrayList<Map<Path, FileAttributes>> result = model.getSyncFiles( pref.getSyncMap(), startSourcePath, startDestPath );
115 				final String scanTimeFormatted = logFormatter.getTimeFormatted( System.nanoTime() - startTime ) + " Laufzeit für das Scannen";
116 
117 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
118 
119 				appendLogData( context, logFormatter.formatMaps( pref.getScanMode(), result.get( 0 ), result.get( 1 ), result.get( 2 ) ) );
120 				appendLogData( context, String.format( "Quelldateien: %d Stück und Zieldateien: %d Stück", stats[0], stats[1] ) );
121 				appendLogData( context, String.format( "Zu löschende Dateien: %d", result.get( 2 ).size() ) );
122 				appendLogData( context,
123 						String.format( "Größe aller Quelldateien: %s | Größe aller Zieldateien: %s", logFormatter.getReadableBytes( stats[2] ), logFormatter.getReadableBytes( stats[3] ) ) );
124 				startTime = System.nanoTime();
125 				final boolean success = model.syncFiles( context, result, pref.getSyncMap(), startSourcePath, startDestPath, false );
126 				final String syncTimeFormatted = logFormatter.getTimeFormatted( System.nanoTime() - startTime ) + " Laufzeit für das Synchronisieren";
127 
128 				if( success ) {
129 					pref.saveLastScanTime();
130 					appendLogData( context, scanTimeFormatted );
131 					appendLogData( context, syncTimeFormatted );
132 					updateUIStatus( context, false, "Synchronisation erfolgreich!" );
133 					Debug.printDebug( "[Controller Helper]  Synchronization completed successfully for profile: %s", context.getJobName() );
134 				}else {
135 					updateUIStatus( context, false, "Synchronisation fehlgeschlagen!" );
136 					Debug.printDebug( "[Controller Helper Error] Synchronization routine failed for: %s", context.getJobName() );
137 				}
138 				Debug.printDebug( "[Controller Helper] Scan Time -> " + scanTimeFormatted );
139 				Debug.printDebug( "[Controller Helper]  Sync Time -> " + syncTimeFormatted );
140 
141 			}catch( final InterruptedException e ) {
142 				updateUIStatus( context, false, "Synchronisation abgebrochen." );
143 				Debug.printDebug( "[Controller Helper Error] Synchronization routine aborted due to interruption: %s", e.getMessage() );
144 				Debug.printException( this.getClass(), e );
145 			}catch( final Exception e ) {
146 				updateUIStatus( context, false, "Fehler: " + e.getMessage() );
147 				Debug.printDebug( "[Controller Helper Error] Synchronization routine failed: %s", e.getMessage() );
148 				Debug.printException( this.getClass(), e );
149 			}
150 			context.setRunning( false );
151 		} );
152 
153 		worker.setDaemon( true );
154 		context.setActiveWorkerThread( worker );
155 		worker.start();
156 	}
157 
158 	/**
159 	 * Runs localized multi-threaded incremental backups using task-isolated rules.
160 	 *
161 	 * @param context Target environment details providing task variables
162 	 */
163 	public void startBackup( final SyncJobContext context ) {
164 		if( context.isRunning() ) return;
165 
166 		context.setRunning( true );
167 		context.setStatusMessage( "Backup gestartet. Analysiere geänderte Daten..." );
168 		context.clearLog();
169 
170 		final Map<Path, FileAttributes> sourceMap = Model.createMap();
171 		final Map<Path, FileAttributes> destMap = Model.createMap();
172 		final Map<Path, FileAttributes> failMap = Model.createMap();
173 		final Preference pref = context.getPreference();
174 		final Model model = new Model( new Logger(), sourceMap, destMap );
175 		final Long[] stats = new Long[4];
176 
177 		final Thread worker = new Thread( () -> {
178 			long startTime = System.nanoTime();
179 			try {
180 				final Path startDestPath = pref.getDestPaths().get( 0 );
181 				if( startDestPath == null || !Files.exists( startDestPath ) ) {
182 					updateUIStatus( context, false, "Kein Ziellaufwerk vorhanden" );
183 					return;
184 				}
185 
186 				failMap.putAll( model.scanSyncFiles( pref.getSourcePaths(), pref.getDestPaths(), stats, pref.getScanMode(), pref.isSubDir(), pref.isTrashbin() ) );
187 				model.compareEqualsFiles();
188 				final String scanTimeFormatted = logFormatter.getTimeFormatted( System.nanoTime() - startTime ) + " Laufzeit für das Scannen";
189 				Debug.printDebug( "[Controller Helper]  sourceMap size = %d, destMap size = %d, failtures = %d", stats[0], stats[1], failMap.size() );
190 
191 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
192 
193 				appendLogData( context, logFormatter.formatMaps( pref.getScanMode(), sourceMap, destMap, failMap ) );
194 				appendLogData( context, String.format( "Quelldateien: %d Stück und Zieldateien: %d Stück", stats[0], stats[1] ) );
195 				appendLogData( context,
196 						String.format( "Größe aller Quelldateien: %s | Größe aller Zieldateien: %s", logFormatter.getReadableBytes( stats[2] ), logFormatter.getReadableBytes( stats[3] ) ) );
197 				appendLogData( context, String.format( "Fehlerhafter Zugriff: %d", failMap.size() ) );
198 
199 				boolean success = false;
200 				String backupTimeFormatted = "";
201 
202 				boolean delete = true;
203 				if( !pref.isAutoDel() ) {
204 					delete = dialogService.promptYesNo( "Dateien löschen", "Löschen bestätigen?", "Alle gelöschten Dateien auch im Zielverzeichnis löschen?" );
205 				}
206 
207 				if( pref.isAutoSync() || dialogService.promptYesNo( "Dateien sichern", "Kopieren bestätigen?", "Alle neuen Dateien in  das Zielverzeichnis kopieren?" ) ) {
208 					startTime = System.nanoTime();
209 					success = model.backupFiles( delete, pref.isLogOn(), startDestPath, pref.isTrashbin(), pref.getTrashbinPath() );
210 					backupTimeFormatted = logFormatter.getTimeFormatted( System.nanoTime() - startTime ) + " Laufzeit für das Synchronisieren";
211 				}
212 //				TODO output at manual abort
213 				if( success ) {
214 					pref.saveLastScanTime();
215 					appendLogData( context, scanTimeFormatted );
216 					appendLogData( context, backupTimeFormatted );
217 					updateUIStatus( context, false, "Backup erfolgreich abgeschlossen!" );
218 					Debug.printDebug( "[Controller Helper]  Backup completed successfully for profile: %s", context.getJobName() );
219 				}else {
220 					updateUIStatus( context, false, "Backup fehlgeschlagen!" );
221 					Debug.printDebug( "[Controller Helper Error] Backup routine failed in: %s", context.getJobName() );
222 				}
223 				Debug.printDebug( "[Controller Helper] Scan Time -> " + scanTimeFormatted );
224 				Debug.printDebug( "[Controller Helper]  Backup Time -> " + backupTimeFormatted );
225 			}catch( final InterruptedException e ) {
226 				updateUIStatus( context, false, "Backup-Vorgang abgebrochen." );
227 				Debug.printDebug( "[Controller Helper Error] Backup routine aborted due to interruption: %s", e.getMessage() );
228 				Debug.printException( this.getClass(), e );
229 			}catch( final Exception e ) {
230 				updateUIStatus( context, false, "Fehler während des Backups: " + e.getMessage() );
231 				Debug.printDebug( "[Controller Helper Error] Backup routine failed: %s", e.getMessage() );
232 				Debug.printException( this.getClass(), e );
233 			}
234 			context.setRunning( false );
235 		} );
236 
237 		worker.setDaemon( true );
238 		context.setActiveWorkerThread( worker );
239 		worker.start();
240 	}
241 
242 	/**
243 	 * Scans source paths asynchronously to list file checksum collisions.
244 	 *
245 	 * @param job Target environment details providing task variables
246 	 */
247 	public void startDuplicateScan( final SyncJobContext job ) {
248 		if( job.isRunning() ) return;
249 
250 		job.setRunning( true );
251 		job.setStatusMessage( "Scanne nach Duplikaten..." );
252 		job.clearLog();
253 
254 		final Map<Path, FileAttributes> sourceMap = Model.createMap();
255 		final Map<Path, FileAttributes> destMap = Model.createMap();
256 		final Long[] stats = new Long[4];
257 
258 		final Preference pref = job.getPreference();
259 		final Model model = new Model( new Logger(), sourceMap, destMap );
260 
261 		final Thread worker = new Thread( () -> {
262 			final long startTime = System.nanoTime();
263 			try {
264 				final Map<Path, FileAttributes> duplicateMap = model.scanDublicates( pref.getSourcePaths(), stats );
265 				final String scanTimeFormatted = logFormatter.getTimeFormatted( System.nanoTime() - startTime ) + "Laufzeit ";
266 
267 				if( Thread.currentThread().isInterrupted() ) throw new InterruptedException( "Manual abort ..." );
268 
269 				final List<SyncJobContext.FileRow> preparedRows = new ArrayList<>();
270 				if( duplicateMap != null && !duplicateMap.isEmpty() ) {
271 					for( final Map.Entry<Path, FileAttributes> entry : duplicateMap.entrySet() ) {
272 						preparedRows.add( new SyncJobContext.FileRow(
273 								entry.getKey(),
274 								entry.getValue(),
275 								logFormatter.getReadableBytes( entry.getValue().getSize() ) ) );
276 					}
277 				}
278 
279 				Platform.runLater( () -> {
280 					job.getDuplicateFiles().clear();
281 					job.getDuplicateFiles().addAll( preparedRows );
282 					job.setRunning( false );
283 					job.setStatusMessage( "Scan abgeschlossen. Duplikate insgesamt gefunden: " + job.getDuplicateFiles().size() + " (" + scanTimeFormatted + "s)" );
284 					Debug.printDebug( "[Controller Helper] duplicateMap size = %d, sourceMap size = %d, failtures = %d", duplicateMap.size(), stats[0], stats[1] );
285 					Debug.printDebug( "[Controller Helper] Scan Time -> " + scanTimeFormatted );
286 				} );
287 			}catch( final InterruptedException e ) {
288 				updateUIStatus( job, false, "Scan abgebrochen." );
289 				Debug.printDebug( "[Controller Helper Error] Duplicate scan aborted due to interruption: %s", e.getMessage() );
290 			}catch( final Exception e ) {
291 				updateUIStatus( job, false, "Fehler beim Duplikat Scan: " + e.getMessage() );
292 				Debug.printDebug( "[Controller Helper Error] Duplicat scan abord: %s", e.getMessage() );
293 				Debug.printException( this.getClass(), e );
294 			}
295 			job.setRunning( false );
296 		} );
297 
298 		worker.setDaemon( true );
299 		job.setActiveWorkerThread( worker );
300 		worker.start();
301 	}
302 
303 	/**
304 	 * Purges marked elements from active disks securely on a detached stack.
305 	 *
306 	 * @param context Target environment details providing task variables
307 	 */
308 	public void deleteSelectedDuplicates( final SyncJobContext context ) {
309 		final ArrayList<SyncJobContext.FileRow> toDelete = new ArrayList<>();
310 		for( final SyncJobContext.FileRow row : context.getDuplicateFiles() ) {
311 			if( row.isSelected() ) {
312 				toDelete.add( row );
313 			}
314 		}
315 
316 		if( toDelete.isEmpty() ) {
317 			context.setStatusMessage( "Keine Dateien zum Löschen ausgewählt." );
318 			return;
319 		}
320 		if( !dialogService.promptYesNo( "Duplikate entfernen", "Löschen bestätigen?", "Alle ausgewählten Dateien wirklich löschen?" ) ) return;
321 
322 		context.setRunning( true );
323 		context.setStatusMessage( "Lösche ausgewählte Duplikate..." );
324 
325 		final Thread worker = new Thread( () -> {
326 			int successCount = 0;
327 			try {
328 				for( final SyncJobContext.FileRow row : toDelete ) {
329 					if( Thread.currentThread().isInterrupted() ) throw new InterruptedException();
330 					if( Files.deleteIfExists( row.getFileSystemPath() ) ) {
331 						successCount++;
332 					}
333 				}
334 				final int finalSuccess = successCount;
335 				Platform.runLater( () -> {
336 					context.getDuplicateFiles().removeIf( SyncJobContext.FileRow::isSelected );
337 					context.setRunning( false );
338 					context.setStatusMessage( finalSuccess + " Duplikate erfolgreich gelöscht." );
339 					Debug.printDebug( "[Controller Helper] Duplicates successfully deleted: %d items.", finalSuccess );
340 				} );
341 			}catch( final InterruptedException e ) {
342 				updateUIStatus( context, false, "Löschvorgang unterbrochen." );
343 				Debug.printDebug( "[Controller Helper Error] Duplicate deletion aborted: %s", e.getMessage() );
344 				Debug.printException( this.getClass(), e );
345 				Thread.currentThread().interrupt();
346 			}catch( final Exception e ) {
347 				updateUIStatus( context, false, "Fehler beim Löschen: " + e.getMessage() );
348 				Debug.printDebug( "[Controller Helper Error] Duplicate deletion failed: %s", e.getMessage() );
349 				Debug.printException( this.getClass(), e );
350 			}
351 		} );
352 
353 		worker.setDaemon( true );
354 		context.setActiveWorkerThread( worker );
355 		worker.start();
356 	}
357 
358 	/**
359 	 * Configures or evicts background host operating system daemon startup triggers.
360 	 * Adjusts system registry run hives on Windows environments or modifies cron execution
361 	 * tables on Linux platforms via secure subprocess execution abstractions.
362 	 *
363 	 * @param set Target flag to dictate whether to register or clear system integration entries.
364 	 * @return true if the underlying system environment sub-process sequences executed without exceptions.
365 	 */
366 	public boolean setOSAutostart( final boolean set ) {
367 		final String javaPath = System.getProperty( "sun.boot.library.path" );
368 		final String exePath = System.getProperty( "jpackage.app-path" );
369 		final String datei = System.getProperty( "sun.java.command" );
370 		final String fullPath = Paths.get( "" ).toAbsolutePath().toString() + System.getProperty( "file.separator" ) + datei;
371 		final String possibleOS = System.getProperty( "os.name" );
372 		String operatingSystem = "";
373 		if( possibleOS != null ) operatingSystem = possibleOS.toLowerCase( Locale.ROOT );
374 		final String flags = computeBootFlags();
375 
376 		if( operatingSystem.contains( "win" ) ) {
377 			final String regCmd = "HKEY_CURRENT_USER\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run";
378 			try {
379 				if( set ) {
380 					// No literal interior quote escapes required; ProcessBuilder insulates whitespaces
381 					final String dataPayload = ( exePath == null )
382 							? String.format( "%s\\javaw.exe -Xmx200m -jar %s %s %s", javaPath, datei, CLIFlags.BOOT_DELAY.getLongFlag(), flags )
383 							: String.format( "%s %s %s", exePath, CLIFlags.BOOT_DELAY.getLongFlag(), flags );
384 
385 					final ProcessBuilder pb = new ProcessBuilder( "reg", "add", regCmd, "/v", "DataSync", "/t", "REG_SZ", "/d", dataPayload, "/f" );
386 					pb.start();
387 				}else {
388 					final ProcessBuilder pb = new ProcessBuilder( "reg", "delete", regCmd, "/v", "DataSync", "/f" );
389 					pb.start();
390 				}
391 			}catch( final IOException e ) {
392 				Debug.printError( "[Controller Helper Error] Set Windows registry autostart tracking hive failed: %s", e.getMessage() );
393 				Debug.printException( getClass(), e );
394 				return false;
395 			}
396 		}else if( operatingSystem.contains( "nix" ) || operatingSystem.contains( "aix" ) || operatingSystem.contains( "nux" ) ) {
397 			final String crontab = "crontab";
398 			try {
399 				if( set ) {
400 					final String cronPayload = ( exePath == null )
401 							? String.format( "@reboot %s/java -jar %s %s %s", javaPath, fullPath, CLIFlags.BOOT_DELAY.getLongFlag(), flags )
402 							: String.format( "@reboot %s %s %s", exePath, CLIFlags.BOOT_DELAY.getLongFlag(), flags );
403 
404 					// Feed crontab structural inputs directly via process input stream pipelining
405 					final ProcessBuilder pb = new ProcessBuilder( crontab, "-" );
406 					final Process process = pb.start();
407 
408 					try( BufferedWriter writer = new BufferedWriter(
409 							new OutputStreamWriter( process.getOutputStream(), StandardCharsets.UTF_8 ) ) ) {
410 						writer.write( cronPayload );
411 						writer.newLine(); // Unix crontab standard strictly requires a trailing newline character
412 					}
413 					process.waitFor();
414 				}else {
415 					final ProcessBuilder pb = new ProcessBuilder( crontab, "-r" );
416 					pb.start().waitFor();
417 				}
418 			}catch( final IOException | InterruptedException e ) {
419 				Debug.printError( "[Controller Helper Error] Set Unix crontab daemon automated launch failed: %s", e.getMessage() );
420 				Debug.printException( getClass(), e );
421 				if( e instanceof InterruptedException ) {
422 					Thread.currentThread().interrupt();
423 				}
424 				return false;
425 			}
426 		}
427 		return true;
428 	}
429 
430 	private String computeBootFlags() {
431 		final StringBuilder stringBuilder = new StringBuilder();
432 		final PreferenceManager manager = PreferenceManager.getInstance();
433 		if( Main.isDebugToFile() ) {
434 			stringBuilder.append( String.format( " %s", CLIFlags.DEBUG_TO_FILE.getLongFlag() ) );
435 		}
436 		if( manager.isCustomConfigDir() ) {
437 			stringBuilder.append( String.format( " %s %s", CLIFlags.CONFIG_DIR.getLongFlag(), manager.getConfigPath().getParent().toString() ) );
438 		}
439 		return stringBuilder.toString();
440 	}
441 
442 	private void updateUIStatus( final SyncJobContext context, final boolean running, final String message ) {
443 		Platform.runLater( () -> {
444 			context.setRunning( running );
445 			context.setStatusMessage( message );
446 		} );
447 	}
448 
449 	private void appendLogData( final SyncJobContext context, final String line ) {
450 		Platform.runLater( () -> context.appendLog( line ) );
451 	}
452 }