View Javadoc
1   package net.sf.okapi.common.filters;
2   
3   import java.lang.reflect.InvocationTargetException;
4   import java.util.ArrayList;
5   import java.util.Collections;
6   import java.util.HashMap;
7   import java.util.HashSet;
8   import java.util.Iterator;
9   import java.util.List;
10  import java.util.Map;
11  import java.util.Set;
12  import java.util.Spliterator;
13  import java.util.concurrent.ConcurrentHashMap;
14  import java.util.function.Consumer;
15  import java.util.function.Function;
16  import java.util.function.Supplier;
17  
18  import org.slf4j.Logger;
19  import org.slf4j.LoggerFactory;
20  
21  import net.sf.okapi.common.IParameters;
22  import net.sf.okapi.common.IParametersEditor;
23  
24  /**
25   * Thread-safe implementation of {@link IFilterConfigurationMapper} that provides
26   * a shared, unified view of filter configurations across all threads.
27   * 
28   * <p>
29   * <b>Key Differences from ThreadSafeFilterConfigurationMapper:</b>
30   * </p>
31   * <ul>
32   * <li>Uses {@link ConcurrentHashMap} for shared configuration storage</li>
33   * <li>Provides consistent view of configurations across all threads</li>
34   * <li>Eliminates thread-local storage overhead</li>
35   * <li>Better suited for applications requiring configuration consistency</li>
36   * </ul>
37   * 
38   * <p>
39   * <b>Thread Safety:</b>
40   * </p>
41   * <ul>
42   * <li>All read operations are lock-free and thread-safe</li>
43   * <li>Write operations use appropriate synchronization</li>
44   * <li>Configuration updates are atomic at the individual configuration level</li>
45   * </ul>
46   * 
47   * @author Okapi Framework Development Team
48   */
49  public class SharedFilterConfigurationMapper implements IFilterConfigurationMapper {
50    private static final Logger logger = LoggerFactory
51        .getLogger(SharedFilterConfigurationMapper.class);
52  
53    /**
54     * Shared thread-safe map of filter configurations, visible to all threads.
55     * Uses ConcurrentHashMap to provide lock-free reads and thread-safe writes.
56     */
57    private final Map<String, FilterConfiguration> configMap;
58  
59    /**
60     * Creates an empty SharedFilterConfigurationMapper.
61     * Initializes with an empty ConcurrentHashMap.
62     */
63    public SharedFilterConfigurationMapper() {
64      this.configMap = new ConcurrentHashMap<>();
65    }
66  
67    /**
68     * Creates a SharedFilterConfigurationMapper with pre-loaded configurations.
69     *
70     * @param initialConfigs initial map of filter configurations to populate the mapper
71     */
72    public SharedFilterConfigurationMapper(Map<String, FilterConfiguration> initialConfigs) {
73      this.configMap = new ConcurrentHashMap<>(initialConfigs);
74    }
75  
76    /**
77     * Creates a SharedFilterConfigurationMapper using a supplier for initial configurations.
78     *
79     * @param fcSupplier supplier that provides the initial map of filter configurations
80     */
81    public SharedFilterConfigurationMapper(Supplier<Map<String, FilterConfiguration>> fcSupplier) {
82      this.configMap = new ConcurrentHashMap<>(fcSupplier.get());
83    }
84  
85    /**
86     * Creates default parameters for a filter configuration.
87     * Attempts to load parameters from various sources in order of priority:
88     * <ol>
89     * <li>Existing parameters in the configuration</li>
90     * <li>Parameters from the specified location</li>
91     * <li>Default reset parameters</li>
92     * </ol>
93     *
94     * @param fc the filter configuration to create parameters for
95     * @return IParameters instance with loaded default parameters
96     */
97    private static IParameters createDefaultParameters(FilterConfiguration fc) {
98      // Create a unique IParameters instance from the filter
99      try (IFilter filter = instantiateFilter(fc.filterClass)) {
100       IParameters params = filter.getParameters();
101 
102       // Priority 1: Use parameters previously loaded in configuration
103       if (fc.parameters != null) {
104         // Load parameters from config as these may have been updated externally
105         params.fromString(fc.parameters.toString());
106         return params;
107       }
108       // Priority 2: Load parameters from specified location
109       else if (fc.parametersLocation != null) {
110         params.load(filter.getClass().getResourceAsStream(fc.parametersLocation), false);
111         return params;
112       }
113 
114       // Priority 3: Use default reset parameters
115       if (params != null) {
116         params.reset();
117       }
118       
119       return params;
120     }
121   }
122 
123   /**
124    * Instantiates a filter using reflection.
125    * Uses the current thread's context class loader for class loading.
126    *
127    * @param filterClass the fully qualified class name of the filter to instantiate
128    * @return new IFilter instance
129    * @throws IllegalArgumentException if filter instantiation fails
130    */
131   private static IFilter instantiateFilter(String filterClass) {
132     try {
133       return (IFilter) Thread.currentThread().getContextClassLoader().loadClass(filterClass)
134           .getDeclaredConstructor().newInstance();
135     } catch (InstantiationException | IllegalAccessException | NoSuchMethodException
136         | InvocationTargetException | ClassNotFoundException e) {
137       throw new IllegalArgumentException("Could not instantiate: " + filterClass, e);
138     }
139   }
140 
141   @Override
142   public void addCustomConfiguration(String configId, IParameters parameters) {
143     // Prevent overwriting existing configurations
144     if (configMap.containsKey(configId)) {
145       return;
146     }
147 
148     FilterConfiguration fc = new FilterConfiguration();
149     fc.custom = true;
150     fc.configId = configId;
151 
152     // Extract filter name from configuration ID
153     String[] res = IFilterConfigurationMapper.splitFilterFromConfiguration(fc.configId);
154 
155     // Create the filter instance (assumes base-name is the default config ID)
156     try (IFilter filter = createFilter(res[0])) {
157       // Populate filter configuration details
158       fc.filterClass = filter.getClass().getName();
159       fc.mimeType = filter.getMimeType();
160       fc.description = "Configuration " + fc.configId;
161       fc.name = fc.configId;
162       fc.parameters = filter.getParameters();
163       fc.parameters.fromString(parameters.toString());
164 
165       // Atomically add to shared map if not present
166       configMap.putIfAbsent(fc.configId, fc);
167       logger.debug("Added custom config: {} with parameters: {}", configId, parameters);
168     }
169   }
170 
171   @Override
172   public void addCustomConfiguration(String configId, String parameters) {
173     // Prevent overwriting existing configurations
174     if (configMap.containsKey(configId)) {
175       return;
176     }
177 
178     FilterConfiguration fc = new FilterConfiguration();
179     fc.custom = true;
180     fc.configId = configId;
181 
182     // Extract filter name from configuration ID
183     String[] res = IFilterConfigurationMapper.splitFilterFromConfiguration(fc.configId);
184 
185     // Create the filter instance (assumes base-name is the default config ID)
186     try (IFilter filter = createFilter(res[0])) {
187       // Populate filter configuration details
188       fc.filterClass = filter.getClass().getName();
189       fc.mimeType = filter.getMimeType();
190       fc.description = "Configuration " + fc.configId;
191       fc.name = fc.configId;
192       fc.parameters = filter.getParameters();
193       fc.parameters.fromString(parameters);
194 
195       // Atomically add to shared map if not present
196       configMap.putIfAbsent(fc.configId, fc);
197       logger.debug("Added custom config: {} with parameters: {}", configId, parameters);
198     }
199   }
200 
201   @Override
202   public void addConfigurations(String filterClass) {
203     // Add all configurations for the specified filter class
204     try (IFilter filter = instantiateFilter(filterClass)) {
205       // Retrieve available configurations for this filter
206       List<FilterConfiguration> list = filter.getConfigurations();
207       if ((list == null) || (list.isEmpty())) {
208         logger.warn("No configuration provided for '{}'", filterClass);
209         return;
210       }
211 
212       for (FilterConfiguration config : list) {
213         // Skip if configuration already exists
214         if (configMap.containsKey(config.configId)) {
215           continue;
216         }
217 
218         // Load default parameters for this configuration
219         config.parameters = createDefaultParameters(config);
220 
221         // Validate and populate missing configuration fields
222         if (config.filterClass == null) {
223           logger.warn("Configuration without filter class name in '{}'", config);
224           config.filterClass = filterClass;
225         }
226         if (config.name == null) {
227           logger.warn("Configuration without name in '{}'", config);
228           config.name = config.toString();
229         }
230         if (config.description == null) {
231           logger.warn("Configuration without description in '{}'", config);
232           config.description = config.toString();
233         }
234 
235         // Atomically add to shared map
236         configMap.putIfAbsent(config.configId, config);
237         logger.debug("Added config: {} with parameters: {}", config.configId, config.parameters);
238       }
239     }
240   }
241 
242   @Override
243   public void addConfiguration(FilterConfiguration config) {
244     // Atomically add single configuration to shared map
245     configMap.putIfAbsent(config.configId, config);
246     logger.debug("Added config: {} with parameters: {}", config.configId, config.parameters);
247   }
248 
249   @Override
250   public IFilter createFilter(String configId, IFilter existingFilter) {
251     // Retrieve configuration for the specified configId
252     FilterConfiguration fc = getConfiguration(configId);
253     if (fc == null) {
254       logger.error("Cannot find filter configuration '{}'", configId);
255       return null;
256     }
257 
258     // For thread safety, always create a new filter instance (ignore existingFilter)
259     try {
260       IFilter filter = instantiateFilter(fc.filterClass);
261       filter.setFilterConfigurationMapper(this);
262 
263       // Apply parameters from configuration to the new filter instance
264       IParameters params = filter.getParameters();
265       if (params != null && fc.parameters != null) {
266         params.fromString(fc.parameters.toString());
267         filter.setParameters(params);
268       }
269 
270       return filter;
271 
272     } catch (Exception e) {
273       logger.error("Error creating filter for configuration '{}'", configId, e);
274       return null;
275     }
276   }
277 
278   @Override
279   public void clearConfigurations(boolean customOnly) {
280     if (customOnly) {
281       // Remove only custom configurations
282       configMap.entrySet().removeIf(entry -> entry.getValue().custom);
283     } else {
284       // Remove all configurations
285       configMap.clear();
286     }
287   }
288 
289   @Override
290   public IFilter createFilter(String configId) {
291     return createFilter(configId, null);
292   }
293 
294   @Override
295   public FilterConfiguration getConfiguration(String configId) {
296     // Thread-safe read from concurrent map
297     return configMap.get(configId);
298   }
299 
300   @Override
301   public FilterConfiguration getDefaultConfiguration(String mimeType) {
302     // Special handling for XLIFF formats that share the same MIME type
303     // Use the autoxliff filter to disambiguate between xliff1.2 and xliff2
304     FilterConfiguration autoXliff = getConfiguration("okf_autoxliff");
305     if (autoXliff != null && autoXliff.mimeType != null && autoXliff.mimeType.equals(mimeType)) {
306       return autoXliff;
307     }
308 
309     FilterConfiguration defaultConfig = null;
310     for (FilterConfiguration config : configMap.values()) {
311       if (config.mimeType == null || !config.mimeType.equals(mimeType)) {
312         continue;
313       }
314 
315       // Assume configurations without parametersLocation are defaults
316       if (config.parametersLocation == null) {
317         return config;
318       }
319 
320       // Fallback: use first registered match if all have parametersLocation
321       if (defaultConfig == null) {
322         defaultConfig = config;
323       }
324     }
325 
326     return defaultConfig;
327   }
328 
329   @Override
330   public FilterConfiguration getDefaultConfigurationFromExtension(String ext) {
331     // Normalize extension format for comparison
332     String tmp = ext.toLowerCase() + ";";
333     if (tmp.charAt(0) != '.') {
334       tmp = ".".concat(tmp);
335     }
336 
337     // Special handling for XLIFF formats that share the same file extensions
338     // Use the autoxliff filter to disambiguate
339     FilterConfiguration autoXliff = getConfiguration("okf_autoxliff");
340     if (autoXliff != null && autoXliff.extensions != null && autoXliff.extensions.contains(tmp)) {
341       return autoXliff;
342     }
343 
344     // Search for configuration matching the file extension
345     for (FilterConfiguration config : configMap.values()) {
346       if (config.extensions != null) {
347         // Ensure extensions string has trailing semicolon for proper matching
348         String configExtensions = config.extensions + ";";
349         if (configExtensions.contains(tmp)) {
350           return config;
351         }
352       }
353     }
354     return null;
355   }
356 
357   @Override
358   public IParameters getParameters(FilterConfiguration config) {
359     if (config == null) {
360       return null;
361     }
362 
363     try (IFilter filter = instantiateFilter(config.filterClass)) {
364       IParameters params = filter.getParameters();
365       if (params == null) {
366         return null;
367       }
368 
369       // Load parameters based on configuration settings
370       if (config.parameters != null) {
371         params.fromString(config.parameters.toString());
372       } else if (config.parametersLocation != null) {
373         params.load(filter.getClass().getResourceAsStream(config.parametersLocation), false);
374       } else {
375         params.reset();
376       }
377 
378       return params;
379     }
380   }
381 
382   @Override
383   public IParameters getParameters(FilterConfiguration config, IFilter existingFilter) {
384     // For thread safety, ignore existingFilter and always create new parameters
385     return getParameters(config);
386   }
387 
388   @Override
389   public FilterConfiguration createCustomConfiguration(FilterConfiguration baseConfig) {
390     // Create a new custom configuration based on the base configuration
391     FilterConfiguration newConfig = new FilterConfiguration();
392     String[] res = IFilterConfigurationMapper.splitFilterFromConfiguration(baseConfig.configId);
393     if (res == null) {
394       return null;
395     }
396 
397     newConfig.custom = true;
398     // Generate unique configuration ID for the copy
399     if (res[1] == null) {
400       newConfig.configId = String.format("%s%ccopy-of-default",
401           res[0], IFilterConfigurationMapper.CONFIGFILE_SEPARATOR);
402     } else {
403       newConfig.configId = String.format("%s%ccopy-of-%s",
404           res[0], IFilterConfigurationMapper.CONFIGFILE_SEPARATOR, res[1]);
405     }
406 
407     // Copy base configuration properties
408     newConfig.classLoader = baseConfig.classLoader;
409     newConfig.name = newConfig.configId;
410     newConfig.description = "Copy of " + baseConfig.description;
411     newConfig.filterClass = baseConfig.filterClass;
412     newConfig.mimeType = baseConfig.mimeType;
413     newConfig.extensions = baseConfig.extensions;
414 
415     // Deep copy parameters
416     if (baseConfig.parameters != null) {
417       try (IFilter filter = instantiateFilter(baseConfig.filterClass)) {
418         IParameters params = filter.getParameters();
419         if (params != null) {
420           params.fromString(baseConfig.parameters.toString());
421           newConfig.parameters = params;
422         }
423       }
424     }
425 
426     return newConfig;
427   }
428 
429   @Override
430   public IParameters getCustomParameters(FilterConfiguration config, IFilter existingFilter) {
431     // Return parameters only for custom configurations
432     if (config.custom && config.parameters != null) {
433       return config.parameters;
434     }
435     return null;
436   }
437 
438   @Override
439   public IParameters getCustomParameters(FilterConfiguration config) {
440     return getCustomParameters(config, null);
441   }
442 
443   @Override
444   public Iterator<FilterConfiguration> getAllConfigurations() {
445     return configMap.values().iterator();
446   }
447 
448   @Override
449   public List<FilterConfiguration> getFilterConfigurations(String filterClass) {
450     List<FilterConfiguration> list = new ArrayList<>();
451     // Find all configurations for the specified filter class
452     for (FilterConfiguration config : configMap.values()) {
453       if (config.filterClass != null && config.filterClass.equals(filterClass)) {
454         list.add(config);
455       }
456     }
457     return list;
458   }
459 
460   @Override
461   public IParametersEditor createConfigurationEditor(String configId, IFilter existingFilter) {
462     throw new UnsupportedOperationException(
463         "Configuration editors are not supported in this implementation");
464   }
465 
466   @Override
467   public IParametersEditor createConfigurationEditor(String configId) {
468     throw new UnsupportedOperationException(
469         "Configuration editors are not supported in this implementation");
470   }
471 
472   @Override
473   public List<FilterInfo> getFiltersInfo() {
474     List<FilterInfo> filtersInfo = new ArrayList<>();
475     Set<String> processedFilterClasses = new HashSet<>();
476 
477     // Collect unique filter information
478     for (FilterConfiguration config : configMap.values()) {
479       if (config.filterClass != null && !processedFilterClasses.contains(config.filterClass)) {
480         try (IFilter filter = instantiateFilter(config.filterClass)) {
481           FilterInfo info = new FilterInfo();
482           info.className = config.filterClass;
483           info.name = filter.getName();
484           info.displayName = filter.getDisplayName();
485           filtersInfo.add(info);
486           processedFilterClasses.add(config.filterClass);
487         } catch (Exception e) {
488           logger.warn("Could not create filter info for {}", config.filterClass, e);
489         }
490       }
491     }
492 
493     // Sort filters alphabetically by name
494     Collections.sort(filtersInfo);
495     return filtersInfo;
496   }
497 
498   @Override
499   public List<FilterConfiguration> getMimeConfigurations(String mimeType) {
500     List<FilterConfiguration> list = new ArrayList<>();
501     // Find all configurations for the specified MIME type
502     for (FilterConfiguration config : configMap.values()) {
503       if (config.mimeType != null && config.mimeType.equals(mimeType)) {
504         list.add(config);
505       }
506     }
507     return list;
508   }
509 
510   @Override
511   public void removeConfiguration(String configId) {
512     configMap.remove(configId);
513   }
514 
515   @Override
516   public Iterator<FilterConfiguration> iterator() {
517     return configMap.values().iterator();
518   }
519 
520   @Override
521   public void forEach(Consumer<? super FilterConfiguration> action) {
522     configMap.values().forEach(action);
523   }
524 
525   @Override
526   public Spliterator<FilterConfiguration> spliterator() {
527     return configMap.values().spliterator();
528   }
529 
530   @Override
531   public void removeConfigurations(String filterClass) {
532     // Remove all configurations for the specified filter class
533     configMap.entrySet().removeIf(entry -> entry.getValue().filterClass.equals(filterClass));
534   }
535 
536   @Override
537   public void saveCustomParameters(FilterConfiguration config, IParameters params) {
538     if (config != null && params != null) {
539       // Update parameters in the shared map with synchronization
540       FilterConfiguration existingConfig = configMap.get(config.configId);
541       if (existingConfig != null) {
542         synchronized (existingConfig) { // Lock on the specific config object for update
543           existingConfig.parameters = params;
544         }
545       }
546     }
547   }
548 
549   @Override
550   public void deleteCustomParameters(FilterConfiguration config) {
551     if (config != null) {
552       // Clear parameters for the specified configuration
553       FilterConfiguration existingConfig = configMap.get(config.configId);
554       if (existingConfig != null) {
555         synchronized (existingConfig) { // Lock on the specific config object for update
556           existingConfig.parameters = null;
557         }
558       }
559     }
560   }
561 
562   /**
563    * Builder for creating and configuring SharedFilterConfigurationMapper instances.
564    * <p>
565    * This builder allows for easy configuration of filter configurations
566    * before creating the mapper.
567    * 
568    * <p>
569    * <b>Usage Example:</b>
570    * </p>
571    * 
572    * <pre>
573    * SharedFilterConfigurationMapper mapper = new SharedFilterConfigurationMapper.ConfigBuilder()
574    *     .addConfigurations(XMLFilter.class)
575    *     .addConfigurations(PlainTextFilter.class)
576    *     .updateConfiguration("okf_xml@custom", "customParams")
577    *     .buildMapper();
578    * </pre>
579    */
580   public static class ConfigBuilder {
581     private final Map<String, FilterConfiguration> configs = new HashMap<>();
582 
583     /**
584      * Adds all configurations from a filter class to the builder.
585      *
586      * @param filterClass the filter class to add configurations from
587      * @return this builder for method chaining
588      * @throws IllegalArgumentException if the filter cannot be instantiated or configurations
589      *                                  loaded
590      */
591     public ConfigBuilder addConfigurations(Class<? extends IFilter> filterClass) {
592       try {
593         try (IFilter filter = instantiateFilter(filterClass.getCanonicalName())) {
594           for (FilterConfiguration config : filter.getConfigurations()) {
595             if (configs.containsKey(config.configId)) {
596               continue;
597             }
598             // Load the default parameters as we may need to update them later
599             config.parameters = createDefaultParameters(config);
600             configs.put(config.configId, config);
601           }
602         }
603         return this;
604       } catch (Exception e) {
605         throw new IllegalArgumentException(
606             "Couldn't add configurations for " + filterClass.getName(), e);
607       }
608     }
609 
610     /**
611      * Updates a specific configuration with new parameters.
612      *
613      * @param configId the configuration ID to update
614      * @param params   the new parameters as a string
615      * @return this builder for method chaining
616      * @throws IllegalArgumentException if the configuration cannot be found
617      */
618     public ConfigBuilder updateConfiguration(String configId, String params) {
619       try {
620         String[] parts = IFilterConfigurationMapper.splitFilterFromConfiguration(configId);
621         // Look up the base filter
622         FilterConfiguration baseFilter = configs.get(parts[0]);
623         if (baseFilter == null) {
624           throw new IllegalArgumentException("Couldn't find base filter for " + configId);
625         }
626         FilterConfiguration fc = configs.get(configId);
627         if (fc == null) {
628           throw new IllegalArgumentException("Couldn't find filter configuration for " + configId);
629         }
630         // Update the parameters
631         fc.parameters.fromString(params);
632         configs.put(configId, fc);
633         return this;
634       } catch (Exception e) {
635         throw new IllegalArgumentException("Couldn't update configurations for " + configId, e);
636       }
637     }
638 
639     /**
640      * Applies a transformation function to all configurations' parameters.
641      *
642      * @param function the function to apply to each configuration's parameters
643      * @return this builder for method chaining
644      */
645     public ConfigBuilder updateConfigurations(Function<IParameters, IParameters> function) {
646       for (FilterConfiguration config : configs.values()) {
647         config.parameters = function.apply(config.parameters);
648       }
649       return this;
650     }
651 
652     /**
653      * Builds the configuration map.
654      *
655      * @return an immutable map of filter configurations
656      */
657     public Map<String, FilterConfiguration> build() {
658       return new HashMap<>(configs);
659     }
660 
661     /**
662      * Builds a SharedFilterConfigurationMapper with the configured settings.
663      *
664      * @return a new SharedFilterConfigurationMapper instance
665      */
666     public SharedFilterConfigurationMapper buildMapper() {
667       return new SharedFilterConfigurationMapper(build());
668     }
669   }
670 }