View Javadoc
1   package de.spiritscorp.datasync.io;
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.IOException;
24  import java.lang.reflect.InvocationTargetException;
25  import java.nio.file.Files;
26  import java.nio.file.LinkOption;
27  import java.nio.file.Path;
28  import java.nio.file.Paths;
29  import java.nio.file.StandardOpenOption;
30  import java.util.ArrayList;
31  import java.util.Collections;
32  import java.util.HashMap;
33  import java.util.List;
34  import java.util.Map;
35  import java.util.concurrent.TimeUnit;
36  import java.util.concurrent.locks.ReentrantLock;
37  
38  import jakarta.json.Json;
39  import jakarta.json.JsonObject;
40  import jakarta.json.JsonObjectBuilder;
41  import jakarta.json.JsonReader;
42  import jakarta.json.JsonWriter;
43  import jakarta.json.JsonWriterFactory;
44  import jakarta.json.stream.JsonGenerator;
45  import jakarta.json.stream.JsonParsingException;
46  
47  import de.spiritscorp.datasync.theme.AppTheme;
48  import de.spiritscorp.datasync.theme.DarkSlateTheme;
49  
50  /**
51   * Thread-safe global configurations orchestrator managing persistent JSON configurations.
52   * Coordinates multi-profile I/O read/write operations for independent replication synchronization tasks
53   * and global application states. Implements the Singleton pattern to ensure centralized state control.
54   *
55   * @author Tom Spirit
56   * @version 1.0.1
57   */
58  public final class PreferenceManager {
59  
60  	/** Root directory for application data storage. */
61  	static final Path DATASYNC_HOME = Paths.get( System.getProperty( "user.home" ), "DataSync" );
62  	/** Root directory for application data storage. */
63  	private Path rootPath = DATASYNC_HOME;
64  	/** Path to the JSON configuration file containing profiles and global settings. */
65  	private Path configPath = rootPath.resolve( "conf.json" );
66  	/** Path to the standard JSON log file. */
67  	private Path logPath = rootPath.resolve( "log.json" );
68  	/** Path to the standard debug log text file. */
69  	private Path debugPath = rootPath.resolve( "debug.log" );
70  	/** Path to the error log text file. */
71  	private Path errorPath = rootPath.resolve( "debug.err" );
72  
73  	/** Timeout duration in seconds for acquiring the profile lock. */
74  	private static final long LOCK_TIME = 1;
75  
76  	/** Thread-safe map storing the loaded automation profiles indexed by their job name. */
77  	private final List<Preference> loadedProfiles = new ArrayList<>();
78  
79  	/** The global Singleton instance of the PreferenceManager. */
80  	private static final PreferenceManager INSTANCE = new PreferenceManager();
81  
82  	/** Lock to ensure thread-safe operations on profile configurations. */
83  	private final ReentrantLock profileLock = new ReentrantLock();
84  
85  	/** Global flag indicating if application launch on systemboot. */
86  	private boolean globalAutoStart;
87  
88  	/** The currently active visual theme of the application. */
89  	private AppTheme theme = new DarkSlateTheme();
90  
91  	/** The maximum file size threshold in bytes before a log file triggers rotation. Defaults to 5,000,000 bytes (5 MB). */
92  	private long maxLogSize = 5_000_000L;
93  
94  	/** The maximum number of historical backup log files to retain. Defaults to 5. */
95  	private int maxLogCount = 5;
96  
97  	/** Enforces non-instantiability outside the Singleton lifecycle context. */
98  	private PreferenceManager() {
99  	}
100 
101 	/**
102 	 * Gets the global Singleton instance.
103 	 *
104 	 * @return The singleton manager instance.
105 	 */
106 	public static PreferenceManager getInstance() { return INSTANCE; }
107 
108 	/**
109 	 * Reconfigures and overrides the global operational ecosystem workspace root coordinates.
110 	 * Re-initializes all system-dependent structural path mappings dynamically.
111 	 * <p>
112 	 * Fallback at <b>(user.home)/DataSync</b> if the target path is not writeable or not valid
113 	 *
114 	 * @param customRoot The new target base directory path, or null to retain the home default context.
115 	 */
116 	public void initGlobalRootConfigPath( final Path customRoot ) {
117 		try {
118 			if( profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
119 				try {
120 					if( customRoot != null &&
121 							Files.exists( customRoot, LinkOption.NOFOLLOW_LINKS ) &&
122 							Files.isDirectory( customRoot, LinkOption.NOFOLLOW_LINKS ) &&
123 							Files.isWritable( customRoot ) ) {
124 						rootPath = customRoot.toAbsolutePath().normalize();
125 						configPath = customRoot.resolve( "conf.json" );
126 						logPath = customRoot.resolve( "log.json" );
127 						debugPath = customRoot.resolve( "debug.log" );
128 						errorPath = customRoot.resolve( "debug.err" );
129 					}
130 				}finally {
131 					profileLock.unlock();
132 				}
133 			}else {
134 				Debug.printError( "[Pref Manager Error] initGlobalRootConfigPath() -> Profiles are allready locked" );
135 			}
136 		}catch( InterruptedException _ ) {
137 			Thread.currentThread().interrupt();
138 		}
139 	}
140 
141 	/**
142 	 * Allocates and provisions a new, distinct profile configuration data scope unit.
143 	 * Automatically appends the freshly constructed tracking unit block into active memory structures.
144 	 *
145 	 * @param jobName Unique target workspace identifier.
146 	 * @return The new configuration instance, or null if the configuration allready exists.
147 	 */
148 	public Preference createProfile( final String jobName, final boolean withSave ) {
149 		try {
150 			if( profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
151 				try {
152 					if( getProfile( jobName ) == null ) {
153 						final Preference pref = Preference.createSinglePreference( jobName );
154 						loadedProfiles.add( pref );
155 						if( !withSave || saveAllPreferences() )
156 							return pref;
157 					}
158 				}finally {
159 					profileLock.unlock();
160 				}
161 			}else {
162 				Debug.printError( "[Pref Manager Error] createProfiles() -> Profiles are allready locked" );
163 			}
164 		}catch( InterruptedException _ ) {
165 			Thread.currentThread().interrupt();
166 		}
167 		return null;
168 	}
169 
170 	/**
171 	 * Retrieves an allocated configuration tracking state context signature via its workspace identifier.
172 	 *
173 	 * @param jobName The unique profile registry lookup key.
174 	 * @return The matching configuration instance state, or null if no mapping tracks the parameter.
175 	 */
176 	public Preference getProfile( final String jobName ) {
177 		for( Preference pref : loadedProfiles ) {
178 			if( jobName.equals( pref.getJobName() ) ) { return pref; }
179 		}
180 		return null;
181 	}
182 
183 	/**
184 	 * Atomically set a copy of an active synchronization job context tracking assignment.
185 	 * Evicts cached properties from memory and updates the primary configuration storage file.
186 	 *
187 	 * @param jobName The high-level UI task context container targeted for decommissioning.
188 	 * @param pref    Associated configuration parameters data segment instance.
189 	 * @return The new configuration copy, or null if the configuration allready exists.
190 	 */
191 	public Preference setNewProfile( final String jobName, final Preference pref ) {
192 		try {
193 			if( profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
194 				try {
195 					if( getProfile( jobName ) == null ) {
196 						Preference newPref = Preference.createSinglePreference( jobName );
197 						newPref.deserialize( pref.serialize() );
198 						newPref.setJobNameFromManager( jobName );
199 						loadedProfiles.addLast( newPref );
200 						if( saveAllPreferences() ) return newPref;
201 					}
202 				}catch( ConfigException _ ) {
203 
204 				}finally {
205 					// Always ensure the lock is released if it was successfully acquired
206 					profileLock.unlock();
207 				}
208 			}else {
209 				Debug.printError( "[Pref Manager Error] setNewProfile() -> Profiles are allready locked" );
210 			}
211 		}catch( InterruptedException _ ) {
212 			// Restore interrupted status if the thread was interrupted while waiting for the lock
213 			Thread.currentThread().interrupt();
214 		}
215 		return null;
216 	}
217 
218 	/**
219 	 * Adjusts the sequential position of an automation profile within the structural execution queue.
220 	 * Mutates the underlying tracking sequence under the active synchronization runtime lock
221 	 * and flushes the updated order to disk immediately.
222 	 *
223 	 * @param newIdx     The target destination index where the profile should be relocated.
224 	 * @param draggedIdx The current source index of the profile being manipulated.
225 	 * @param pref       Associated configuration parameters data segment instance.
226 	 * @return true if reordering and persistence succeeded; false if parameters were invalid or execution failed.
227 	 */
228 	public boolean moveProfile( final int newIdx, final int draggedIdx, final Preference pref ) {
229 		try {
230 			if( profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
231 				try {
232 					if( pref != null && draggedIdx != newIdx ) {
233 						loadedProfiles.remove( draggedIdx );
234 						loadedProfiles.add( newIdx, pref );
235 						return saveAllPreferences();
236 					}
237 				}finally {
238 					// Always ensure the lock is released if it was successfully acquired
239 					profileLock.unlock();
240 				}
241 			}else {
242 				Debug.printError( "[Pref Manager Error] setProfile() -> Profiles are allready locked" );
243 			}
244 		}catch( InterruptedException _ ) {
245 			// Restore interrupted status if the thread was interrupted while waiting for the lock
246 			Thread.currentThread().interrupt();
247 		}
248 		return false;
249 	}
250 
251 	/**
252 	 * Atomically handles profile rename routines inside the synchronization runtime context.
253 	 * Mutates the tracking key structural state identifier map and flushes changes to disk immediately.
254 	 *
255 	 * @param oldName Original task profile identifier key.
256 	 * @param newName Target replacement unique identifier string.
257 	 * @param pref    Associated configuration parameters data segment instance.
258 	 * @return true if persistence succeeded; false if parameters were invalid or execution failed.
259 	 */
260 	public boolean renameProfile( final String oldName, final String newName, final Preference pref ) {
261 		try {
262 			// Try to acquire the lock within a 1-second timeout to prevent deadlocks
263 			if( profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
264 				try {
265 					// Execute only when all inputs are valid
266 					if( oldName != null && newName != null && pref != null && !oldName.equals( newName ) ) {
267 						pref.setJobNameFromManager( newName );
268 						return saveAllPreferences();
269 					}
270 				}finally {
271 					// Always ensure the lock is released if it was successfully acquired
272 					profileLock.unlock();
273 				}
274 			}else {
275 				Debug.printError( "[Pref Manager Error] renameProfile() -> Profiles are allready locked" );
276 			}
277 		}catch( InterruptedException _ ) {
278 			// Restore interrupted status if the thread was interrupted while waiting for the lock
279 			Thread.currentThread().interrupt();
280 		}
281 		return false;
282 	}
283 
284 	/**
285 	 * Atomically removes an active synchronization job context tracking assignment.
286 	 * Evicts cached properties from memory and updates the primary configuration storage file.
287 	 *
288 	 * @param jobName The job name targeted for decommissioning.
289 	 * @return true if jobname exists and deleted successfuly.
290 	 */
291 	public boolean removeProfile( final String jobName ) {
292 		try {
293 			if( profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
294 				try {
295 					Preference pref = getProfile( jobName );
296 					if( pref != null ) {
297 						pref.removeProfile();
298 						loadedProfiles.remove( pref );
299 						return saveAllPreferences();
300 					}
301 				}finally {
302 					profileLock.unlock();
303 				}
304 			}else {
305 				Debug.printError( "[Pref Manager Error] removeProfile() -> Profiles are allready locked" );
306 			}
307 		}catch( InterruptedException _ ) {
308 			Thread.currentThread().interrupt();
309 		}
310 		return false;
311 	}
312 
313 	/**
314 	 * Exposes the active, in-memory configuration profile list registry.
315 	 * Wrapped in an unmodifiable view to preserve structural mutation thread safety bounds.
316 	 *
317 	 * @return An unmodifiable structural read-only view tracking live preference profiles.
318 	 */
319 	public List<Preference> getLoadedProfiles() { return Collections.unmodifiableList( loadedProfiles ); }
320 
321 	/**
322 	 * Compiles all active in-memory profile matrices and flushes them into a single unified JSON structure.
323 	 * Truncates any existing configuration state assets dynamically during filesystem stream allocation.
324 	 *
325 	 * @return true if structural file flushing and underlying persistence executed without errors.
326 	 */
327 	public boolean saveAllPreferences() {
328 		try {
329 			if( profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
330 				try {
331 					if( !Files.exists( rootPath ) ) {
332 						Files.createDirectories( rootPath );
333 					}
334 
335 					final JsonObjectBuilder rootBuilder = Json.createObjectBuilder();
336 
337 					// Embed global properties
338 					final JsonObject globalDoc = Json.createObjectBuilder()
339 							.add( "autoStart", globalAutoStart )
340 							.add( "theme", theme.getClass().getName() )
341 							.add( "maxLogSize", maxLogSize )
342 							.add( "maxLogCount", maxLogCount )
343 							.build();
344 					rootBuilder.add( "globalSettings", globalDoc );
345 
346 					// Append dynamic profile segments
347 					for( final Preference entry : loadedProfiles ) {
348 						rootBuilder.add( entry.getJobName(), entry.serialize() );
349 					}
350 
351 					final Map<String, Object> writerConfig = new HashMap<>();
352 					writerConfig.put( JsonGenerator.PRETTY_PRINTING, true );
353 					final JsonWriterFactory factory = Json.createWriterFactory( writerConfig );
354 
355 					try( JsonWriter writer = factory.createWriter( Files.newOutputStream( configPath, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING ) ) ) {
356 						writer.write( rootBuilder.build() );
357 						return true;
358 					}
359 				}catch( final IOException exception ) {
360 					Debug.printDebug( "[Pref Manager Error] Critical: Failed to serialize active memory states to 'conf.json'. Reason: %s", exception.getMessage() );
361 					Debug.printException( this.getClass(), exception );
362 					return false;
363 				}finally {
364 					profileLock.unlock();
365 				}
366 			}else {
367 				Debug.printError( "[Pref Manager Error] removeProfile() -> Profiles are allready locked" );
368 			}
369 		}catch( InterruptedException _ ) {
370 			Thread.currentThread().interrupt();
371 		}
372 		return false;
373 	}
374 
375 	/**
376 	 * Hydrates the global storage matrix layer and multi-job profile cache records from the persistent disk token.
377 	 * Validates individual structural segments during structural ingestion parsing.
378 	 *
379 	 * @return true if filesystem parsing completed entirely; false if tracking token was absent or corrupt.
380 	 */
381 	public boolean loadAllPreferences() {
382 		if( !Files.exists( configPath, LinkOption.NOFOLLOW_LINKS ) ) {
383 			Debug.printDebug( "[Pref Manager Warn] Config file not available. Save to create it." );
384 			return false;
385 		}
386 		try {
387 			if( !profileLock.tryLock( LOCK_TIME, TimeUnit.SECONDS ) ) {
388 				Debug.printError( "[Pref Manager Error] Profiles are allready locked" );
389 				return false;
390 			}
391 		}catch( InterruptedException _ ) {
392 			Thread.currentThread().interrupt();
393 			return false;
394 		}
395 
396 		try( JsonReader reader = Json.createReader( Files.newInputStream( configPath ) ) ) {
397 			final JsonObject rootObj = reader.readObject();
398 			if( rootObj.isEmpty() ) return false;
399 
400 			// Extract global runtime parameters
401 			if( !extractGlobal( rootObj ) ) {
402 				Debug.printDebug( "[Pref Manager Warn] load globals incompleted" );
403 			}
404 			loadedProfiles.clear();
405 
406 			// Extract distinct automation tasks profiles
407 			if( !extractProfiles( rootObj ) ) {
408 				Debug.printDebug( "[Pref Manager Warn] load profiles incompleted" );
409 			}
410 			return true;
411 		}catch( final JsonParsingException | ClassCastException | IOException exception ) {
412 			Debug.printDebug( "[Error] Critical: Failed to load profiles. Reason: %s", exception.getMessage() );
413 			Debug.printException( this.getClass(), exception );
414 			return false;
415 		}finally {
416 			profileLock.unlock();
417 		}
418 	}
419 
420 	private boolean extractGlobal( final JsonObject rootObj ) {
421 		if( rootObj.containsKey( "globalSettings" ) ) {
422 			final JsonObject globalDoc = rootObj.getJsonObject( "globalSettings" );
423 			if( globalDoc == null ) return false;
424 			this.globalAutoStart = globalDoc.getBoolean( "autoStart", false );
425 			this.maxLogCount = globalDoc.getInt( "maxLogCount", 5 );
426 			if( globalDoc.containsKey( "maxLogSize" ) ) {
427 				try {
428 					this.maxLogSize = globalDoc.getJsonNumber( "maxLogSize" ).longValueExact();
429 				}catch( final ClassCastException | JsonParsingException | ArithmeticException exception ) {
430 					Debug.printException( getClass(), exception );
431 				}
432 			}
433 			if( globalDoc.containsKey( "theme" ) ) {
434 				final String className = globalDoc.getString( "theme" );
435 				try {
436 					final Class<?> themeClass = Class.forName( className );
437 					this.theme = (AppTheme) themeClass.getDeclaredConstructor().newInstance();
438 					return true;
439 				}catch( final ClassNotFoundException | InstantiationException | IllegalAccessException | IllegalArgumentException
440 						| InvocationTargetException | NoSuchMethodException | SecurityException exception ) {
441 					Debug.printDebug( "[Error] Falling back to default. Failed to instantiate theme class: %s", exception.getMessage() );
442 					Debug.printException( getClass(), exception );
443 				}
444 			}else {
445 				Debug.printDebug( "[Pref Manager Warn] No value for instantiate theme class. Falling back to default." );
446 			}
447 		}
448 		return false;
449 	}
450 
451 	private boolean extractProfiles( final JsonObject rootObj ) {
452 		for( final String jobName : rootObj.keySet() ) {
453 			if( jobName.equals( "globalSettings" ) ) continue;
454 			final JsonObject jobData = rootObj.getJsonObject( jobName );
455 			final Preference pref = Preference.createSinglePreference( jobName );
456 			try {
457 				pref.deserialize( jobData );
458 				loadedProfiles.add( pref );
459 			}catch( final ConfigException exception ) {
460 				Debug.printDebug( "[Pref Manager Error] Critical: Failed to load job profile '%s'. Skipping entry. Reason: %s", jobName, exception.getMessage() );
461 				Debug.printException( this.getClass(), exception );
462 				return false;
463 			}
464 		}
465 		return !loadedProfiles.isEmpty();
466 	}
467 
468 	// --- Global Configuration Accessors ---
469 
470 	/**
471 	 * Evaluates whether the application ecosystem is provisioned to launch automatically
472 	 * upon host operating system startup sequences.
473 	 *
474 	 * @return true if the global background autostart configuration sequence is enabled.
475 	 */
476 	public boolean isGlobalAutoStart() { return globalAutoStart; }
477 
478 	/**
479 	 * Modifies the global automation startup property state parameter.
480 	 * This execution updates the structural configuration variable memory cache layers.
481 	 *
482 	 * @param globalAutoStart Target state flag to determine automated deployment behavior.
483 	 */
484 	public void setGlobalAutoStart( final boolean globalAutoStart ) { this.globalAutoStart = globalAutoStart; }
485 
486 	// --- Instanced System Properties Accessors (Formerly Static) ---
487 
488 	/**
489 	 * Retrieves the persistent dynamic configuration storage path target locator.
490 	 *
491 	 * @return The absolute filesystem path directing to the primary 'conf.json' token.
492 	 */
493 	public Path getConfigPath() { return configPath; }
494 
495 	/**
496 	 * Retrieves the persistent operational event synchronization logging path locator.
497 	 *
498 	 * @return The absolute filesystem path directing to the structural 'log.json' entity.
499 	 */
500 	public Path getLogPath() { return logPath; }
501 
502 	/**
503 	 * Retrieves the system console output debug log tracking path locator.
504 	 *
505 	 * @return The absolute filesystem path directing to the standard runtime 'debug.log' file.
506 	 */
507 	public Path getDebugPath() { return debugPath; }
508 
509 	/**
510 	 * Retrieves the localized tracking error diagnostic path locator.
511 	 *
512 	 * @return The absolute filesystem path directing to the critical runtime 'debug.err' stream dump.
513 	 */
514 	public Path getErrorPath() { return errorPath; }
515 
516 	/**
517 	 * Sets the visual theme of the application.
518 	 *
519 	 * @param theme The new AppTheme
520 	 */
521 	public void setTheme( final AppTheme theme ) { this.theme = theme; }
522 
523 	/**
524 	 * Gets the currently configured application theme.
525 	 *
526 	 * @return the active AppTheme
527 	 */
528 	public AppTheme getTheme() { return theme; }
529 
530 	/**
531 	 * Verifies whether the active execution environment deviates from the standard system workspace path.
532 	 * Evaluates the structural equality of the root path against the default deployment home directory.
533 	 *
534 	 * @return true if a user-defined custom configuration directory is active; false if using the default home path.
535 	 */
536 	public boolean isCustomConfigDir() { return !DATASYNC_HOME.equals( rootPath ); }
537 
538 	/**
539 	 * Retrieves the absolute configuration root storage path locator.
540 	 *
541 	 * @return The absolute filesystem path directing to the root config path.
542 	 */
543 	public Path getRootPath() { return rootPath; }
544 
545 	/**
546 	 * Returns the maximum size threshold in bytes before a log file is rotated.
547 	 *
548 	 * @return the maximum log file size in bytes
549 	 */
550 	public long getMaxLogSize() { return maxLogSize; }
551 
552 	/**
553 	 * Returns the maximum number of historical backup log files to retain.
554 	 *
555 	 * @return the maximum allowed number of backup files
556 	 */
557 	public int getMaxLogCount() { return maxLogCount; }
558 }