View Javadoc
1   package com.acumenvelocity.ath.common;
2   
3   import java.io.File;
4   import java.io.FileInputStream;
5   import java.io.FileOutputStream;
6   import java.io.IOException;
7   import java.io.UncheckedIOException;
8   import java.lang.reflect.Method;
9   import java.net.URI;
10  import java.net.URLEncoder;
11  import java.nio.file.Files;
12  import java.nio.file.Path;
13  import java.nio.file.Paths;
14  import java.nio.file.attribute.PosixFilePermissions;
15  import java.text.DateFormat;
16  import java.time.Instant;
17  import java.util.ArrayList;
18  import java.util.Collections;
19  import java.util.Date;
20  import java.util.List;
21  import java.util.Objects;
22  import java.util.Optional;
23  import java.util.UUID;
24  import java.util.zip.ZipEntry;
25  import java.util.zip.ZipInputStream;
26  import java.util.zip.ZipOutputStream;
27  
28  import com.fasterxml.jackson.databind.JsonNode;
29  
30  import net.sf.okapi.common.BOMAwareInputStream;
31  import net.sf.okapi.common.StreamUtil;
32  import net.sf.okapi.common.Util;
33  
34  /**
35   * Utility class providing commonly used helper methods for
36   * null-safe operations, conversions, and lightweight object handling.
37   * 
38   * @author Acumen Velocity
39   * @version 1.0
40   * @since 1.0
41   */
42  public class AthUtil {
43  
44    /**
45     * Returns the given value if non-null, otherwise returns a default value.
46     *
47     * @param <T>    the type of the values
48     * @param val    the candidate value
49     * @param defVal the default value if {@code val} is {@code null}
50     * @return {@code val} if not {@code null}, otherwise {@code defVal}
51     */
52    public static <T> T fallback(T val, T defVal) {
53      return Optional.ofNullable(val).orElse(defVal);
54    }
55  
56    /**
57     * Converts the given object to its string representation,
58     * or returns a fallback string if the object is null.
59     *
60     * @param val the object to stringify
61     * @return the string representation or {@code defVal} if {@code val} is null
62     */
63    public static String safeToStr(Object val, String defVal) {
64      return val == null ? defVal : val.toString();
65    }
66  
67    /**
68     * Returns the provided string if non-empty, otherwise returns defVal.
69     *
70     * @param val the candidate string
71     * @return the string representation or {@code defVal} if {@code val} is null
72     */
73    public static String safeToStr(String val, String defVal) {
74      return Util.isEmpty(val) ? defVal : val;
75    }
76  
77    /**
78     * Attempts to parse an integer from a string.
79     * Returns the provided default value if parsing fails.
80     *
81     * @param val    the string to parse
82     * @param defVal the default value to return on failure
83     * @return parsed integer or {@code defVal} if parsing fails
84     */
85    public static Integer safeToInt(String val, int defVal) {
86      try {
87        return Integer.valueOf(val);
88  
89      } catch (Exception e) {
90        return defVal;
91      }
92    }
93  
94    public static UUID safeToUuid(String val, UUID defVal) {
95      try {
96        return UUID.fromString(val);
97  
98      } catch (Exception e) {
99        return defVal;
100     }
101   }
102 
103   public static List<UUID> safeToUuidList(Object val, List<UUID> defVal) {
104     if (val == null) {
105       return defVal != null ? defVal : Collections.emptyList();
106     }
107 
108     List<UUID> result = new ArrayList<>();
109 
110     if (val instanceof List) {
111       // Handle multi-valued field from Solr (e.g., List<String>)
112       for (Object item : (List<?>) val) {
113         if (item != null) {
114           UUID uuid = safeToUuid(item.toString(), null);
115 
116           if (uuid != null) {
117             result.add(uuid);
118           }
119         }
120       }
121     } else if (val instanceof String) {
122       // Handle comma-separated string of UUIDs
123       String[] uuidStrings = val.toString().split(",");
124 
125       for (String uuidStr : uuidStrings) {
126         uuidStr = uuidStr.trim();
127 
128         if (!uuidStr.isEmpty()) {
129           UUID uuid = safeToUuid(uuidStr, null);
130 
131           if (uuid != null) {
132             result.add(uuid);
133           }
134         }
135       }
136     }
137 
138     return result.isEmpty() && defVal != null ? defVal : result;
139   }
140 
141   public static <E extends Enum<E>> E safeToEnum(String val, Class<E> enumClass, E defVal) {
142     if (val == null) {
143       return defVal;
144     }
145 
146     try {
147       // First try standard valueOf (matches enum constant name)
148       return Enum.valueOf(enumClass, val);
149 
150     } catch (IllegalArgumentException e) {
151       // If that fails, try to find a fromValue method (common in generated enums)
152       try {
153         Method fromValue = enumClass.getMethod("fromValue", String.class);
154         return enumClass.cast(fromValue.invoke(null, val));
155 
156       } catch (Exception ex) {
157         return defVal;
158       }
159     }
160   }
161 
162   /**
163    * Adds a non-null element to a list.
164    *
165    * @param <E>  the element type
166    * @param list the list to add to
167    * @param elem the element to add (ignored if null)
168    * @return true if the element was added, false if {@code elem} was null
169    */
170   public static <E> boolean safeAddToList(List<E> list, E elem) {
171     if (elem == null) {
172       return false;
173     }
174     return list.add(elem);
175   }
176 
177   /**
178    * Returns the last {@code numChars} characters of a string.
179    *
180    * @param st       the string
181    * @param numChars the number of trailing characters to return
182    * @return substring of last {@code numChars}, or empty string if
183    *         {@code st} is null/empty or shorter than {@code numChars}
184    */
185   public static String lastChars(String st, int numChars) {
186     if (Util.isEmpty(st) || st.length() < numChars) {
187       return "";
188     }
189     return st.substring(st.length() - numChars);
190   }
191 
192   /**
193    * Creates a shallow copy of the given object using JSON serialization.
194    * <p>
195    * This relies on {@link JacksonUtil} for serialization and deserialization.
196    * Note that it is not a deep clone; nested objects may be shared.
197    *
198    * @param <T> the object type
199    * @param obj the object to clone
200    * @param cls the class type
201    * @return a shallow copy of {@code obj}
202    */
203   public static <T> T clone(T obj, Class<T> cls) {
204     return JacksonUtil.fromJson(JacksonUtil.toJson(obj, false), cls);
205   }
206 
207   public static <T> List<T> removeNulls(List<T> list) {
208     list.removeIf(Objects::isNull);
209     return list;
210   }
211 
212   public static String fileAsString(final File file) throws IOException {
213     try (final BOMAwareInputStream bis = new BOMAwareInputStream(new FileInputStream(file),
214         "UTF-8")) {
215       return StreamUtil.streamAsString(bis, bis.detectEncoding());
216     }
217   }
218 
219   /**
220    * Safely converts a JsonNode to a specified class type.
221    * Returns defVal if conversion fails or node is null.
222    */
223   public static <T> T safeFromJsonNode(JsonNode node, Class<T> clazz, T defVal) {
224     if (node == null) {
225       return defVal;
226     }
227     try {
228       return JacksonUtil.fromJsonNode(node, clazz);
229     } catch (Exception e) {
230       return defVal;
231     }
232   }
233 
234   /**
235    * Safely converts a Date object from an object.
236    * Returns defVal if field is null or not a Date.
237    */
238   public static Date safeToDate(Object val, Date defVal) {
239     if (val == null) {
240       return defVal;
241     }
242     try {
243       return (Date) val;
244     } catch (ClassCastException e) {
245       return defVal;
246     }
247   }
248 
249   /**
250    * Safely parses a Long from a string.
251    * Returns defVal if parsing fails or val is null.
252    */
253   public static Long safeToLong(String val, Long defVal) {
254     if (val == null) {
255       return defVal;
256     }
257     try {
258       return Long.parseLong(val);
259     } catch (NumberFormatException e) {
260       return defVal;
261     }
262   }
263 
264   /**
265    * Safely parses a Float from a string or object.
266    * Returns defVal if parsing fails or val is null.
267    */
268   public static Float safeToFloat(Object val, Float defVal) {
269     if (val == null) {
270       return defVal;
271     }
272     try {
273       if (val instanceof Float) {
274         return (Float) val;
275       }
276       return Float.parseFloat(val.toString());
277     } catch (Exception e) {
278       return defVal;
279     }
280   }
281 
282   /**
283    * Safely parses a Float from a string or object.
284    * Returns defVal if parsing fails or val is null.
285    */
286   public static Double safeToDouble(Object val, Double defVal) {
287     if (val == null) {
288       return defVal;
289     }
290 
291     try {
292       if (val instanceof Double) {
293         return (Double) val;
294       }
295 
296       return Double.parseDouble(val.toString());
297 
298     } catch (Exception e) {
299       return defVal;
300     }
301   }
302 
303   /**
304    * Safely parses an ISO-8601 date string from JsonNode.
305    * Returns defVal if parsing fails.
306    */
307   public static Date safeToDateFromIso(String isoDateStr, Date defVal) {
308     if (Util.isEmpty(isoDateStr)) {
309       return defVal;
310     }
311     try {
312       return Date.from(Instant.parse(isoDateStr));
313     } catch (Exception e) {
314       return defVal;
315     }
316   }
317 
318   public static String dateToString(DateFormat dateFormat, Date date) {
319     return dateFormat.format(date);
320   }
321 
322   public static File createTempFile() {
323     return createTempFile(Const.TEMP_PREFIX, null);
324   }
325 
326   public static File createTempFile(String suffix) {
327     return createTempFile(Const.TEMP_PREFIX, suffix);
328   }
329 
330   public static File createTempFile(String prefix, String suffix) {
331     File file = null;
332 
333     try {
334       file = File.createTempFile(prefix, suffix);
335       file.deleteOnExit();
336 
337       // Set the file permissions to allow only the owner to read and write
338       try {
339         Files.setPosixFilePermissions(file.toPath(),
340             PosixFilePermissions.fromString("rw-------"));
341 
342       } catch (UnsupportedOperationException e) {
343         // Non-POSIX OS like Windows
344         file.setReadable(true);
345         file.setWritable(true);
346       }
347 
348       return file;
349 
350     } catch (Exception e) {
351       Log.warn(AthUtil.class, "Cannot create a temp file: {}", e.getMessage());
352       return null;
353     }
354   }
355 
356   /**
357    * Converts a string to URI, encoding the path component if needed
358    * Handles any URI scheme (gs://, http://, https://, file://, etc.)
359    */
360   public static URI toURI(String uriString) {
361     if (Util.isEmpty(uriString)) {
362       return null;
363     }
364 
365     try {
366       String trimmed = uriString.trim();
367 
368       // Try direct creation first
369       try {
370         return URI.create(trimmed);
371 
372       } catch (IllegalArgumentException e) {
373         // If direct creation fails, try encoding the path
374       }
375 
376       // Parse the URI to encode only the path component
377       int schemeEnd = trimmed.indexOf("://");
378 
379       if (schemeEnd == -1) {
380         // No scheme, treat as path only
381         return URI.create(encodePath(trimmed));
382       }
383 
384       String scheme = trimmed.substring(0, schemeEnd);
385       String rest = trimmed.substring(schemeEnd + 3); // After "://"
386 
387       // Find where authority ends and path begins
388       int pathStart = rest.indexOf('/');
389 
390       if (pathStart == -1) {
391         // No path, just scheme://authority
392         return URI.create(trimmed);
393       }
394 
395       String authority = rest.substring(0, pathStart);
396       String pathAndQuery = rest.substring(pathStart + 1);
397 
398       // Split path from query/fragment
399       int queryStart = pathAndQuery.indexOf('?');
400       int fragmentStart = pathAndQuery.indexOf('#');
401 
402       String path;
403       String suffix = "";
404 
405       if (queryStart != -1) {
406         path = pathAndQuery.substring(0, queryStart);
407         suffix = pathAndQuery.substring(queryStart);
408 
409       } else if (fragmentStart != -1) {
410         path = pathAndQuery.substring(0, fragmentStart);
411         suffix = pathAndQuery.substring(fragmentStart);
412 
413       } else {
414         path = pathAndQuery;
415       }
416 
417       // Encode the path segments
418       String encodedPath = encodePath(path);
419 
420       return URI.create(scheme + "://" + authority + "/" + encodedPath + suffix);
421 
422     } catch (Exception e) {
423       return null;
424     }
425   }
426 
427   /**
428    * Encodes each segment of a path
429    */
430   private static String encodePath(String path) throws Exception {
431     if (path.isEmpty()) {
432       return path;
433     }
434 
435     String[] segments = path.split("/", -1); // -1 to preserve trailing empty strings
436     StringBuilder encoded = new StringBuilder();
437 
438     for (int i = 0; i < segments.length; i++) {
439       if (i > 0)
440         encoded.append("/");
441 
442       if (!segments[i].isEmpty()) {
443         encoded.append(URLEncoder.encode(segments[i], "UTF-8")
444             .replace("+", "%20")); // Use %20 instead of + for spaces
445       }
446     }
447 
448     return encoded.toString();
449   }
450 
451   /**
452    * Extracts the last section after the last slash in a path.
453    * 
454    * @param path the input path string
455    * @return the last section after the last slash, or empty string if no slash found
456    * @throws NullPointerException if the input path is null
457    */
458   public static String extractLastSection(String path) {
459     Objects.requireNonNull(path, "Path cannot be null");
460 
461     int lastSlashIndex = path.lastIndexOf('/');
462 
463     if (lastSlashIndex == -1) {
464       return "";
465     }
466 
467     return path.substring(lastSlashIndex + 1);
468   }
469 
470   /**
471    * Extracts the last section after the last slash, with a fallback value.
472    * 
473    * @param path         the input path string
474    * @param defaultValue the value to return if no section can be extracted
475    * @return the last section after slash, or defaultValue if not found
476    */
477   public static String extractLastSectionOrDefault(String path, String defaultValue) {
478     if (path == null || path.isEmpty()) {
479       return defaultValue;
480     }
481 
482     int lastSlashIndex = path.lastIndexOf('/');
483     if (lastSlashIndex == -1 || lastSlashIndex == path.length() - 1) {
484       return defaultValue;
485     }
486 
487     return path.substring(lastSlashIndex + 1);
488   }
489 
490   public static String nullIfEmpty(JsonNode node) {
491     if (node == null || node.isNull() || node.asText("").trim().isEmpty()) {
492       return null;
493     }
494     return node.asText();
495   }
496 
497   public static Integer safeToInt(JsonNode node, Integer defaultValue) {
498     if (node == null || node.isNull() || !node.canConvertToInt()) {
499       return defaultValue;
500     }
501     return node.asInt();
502   }
503 
504   public static UUID safeToUuid(JsonNode node, UUID defaultValue) {
505     if (node == null || node.isNull()) {
506       return defaultValue;
507     }
508     String text = node.asText(null);
509     return safeToUuid(text, defaultValue);
510   }
511 
512   public static void deleteFile(File file) {
513     if (file != null && file.exists()) {
514       if (!file.delete()) {
515         file.deleteOnExit();
516       }
517     }
518   }
519 
520   public static void deleteDirectory(File dir) {
521     File[] files = dir.listFiles();
522 
523     if (files != null) {
524       for (File file : files) {
525         if (file.isDirectory()) {
526           deleteDirectory(file);
527 
528         } else {
529           file.delete();
530         }
531       }
532     }
533 
534     dir.delete();
535   }
536   
537   public static void zip(String sourceDirPath, String zipFilePath) throws IOException {
538     Path source = Paths.get(sourceDirPath);
539 
540     try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFilePath))) {
541       Files.walk(source).filter(path -> !Files.isDirectory(path)).forEach(path -> {
542         ZipEntry entry = new ZipEntry(source.relativize(path).toString().replace("\\", "/"));
543 
544         try {
545           zos.putNextEntry(entry);
546           Files.copy(path, zos);
547           zos.closeEntry();
548 
549         } catch (IOException e) {
550           throw new UncheckedIOException(e);
551         }
552       });
553     }
554   }
555 
556   public static void unzip(String zipFilePath, String destDir) throws IOException {
557     File dir = new File(destDir);
558 
559     if (!dir.exists()) {
560       dir.mkdirs();
561     }
562 
563     try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFilePath))) {
564       ZipEntry entry;
565 
566       while ((entry = zis.getNextEntry()) != null) {
567         File file = new File(destDir, entry.getName());
568 
569         if (entry.isDirectory()) {
570           file.mkdirs();
571 
572         } else {
573           file.getParentFile().mkdirs();
574 
575           try (FileOutputStream fos = new FileOutputStream(file)) {
576             byte[] buffer = new byte[4096];
577             int len;
578 
579             while ((len = zis.read(buffer)) > 0) {
580               fos.write(buffer, 0, len);
581             }
582           }
583         }
584 
585         zis.closeEntry();
586       }
587     }
588   }
589 }