001package net.filebot.format;
002
003import static java.util.Arrays.*;
004import static java.util.regex.Pattern.*;
005import static net.filebot.format.ExpressionFormatFunctions.*;
006import static net.filebot.similarity.ICU.*;
007import static net.filebot.similarity.Normalization.*;
008import static net.filebot.util.RegularExpressions.*;
009
010import java.io.File;
011import java.math.BigDecimal;
012import java.time.Instant;
013import java.time.LocalDateTime;
014import java.time.ZoneId;
015import java.time.ZonedDateTime;
016import java.time.temporal.Temporal;
017import java.time.temporal.TemporalAmount;
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Date;
021import java.util.List;
022import java.util.Locale;
023import java.util.Map;
024import java.util.Map.Entry;
025import java.util.Objects;
026import java.util.TreeMap;
027import java.util.function.Function;
028import java.util.regex.Matcher;
029import java.util.regex.Pattern;
030import java.util.stream.Collectors;
031import java.util.stream.IntStream;
032import java.util.stream.Stream;
033
034import org.codehaus.groovy.runtime.DefaultGroovyMethods;
035import org.codehaus.groovy.runtime.StringGroovyMethods;
036import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
037
038import groovy.lang.Closure;
039
040import net.filebot.InvalidInputException;
041import net.filebot.media.MediaDetection;
042import net.filebot.util.DateTimeUtilities;
043import net.filebot.util.FileUtilities;
044import net.filebot.util.StringUtilities;
045
046public class ExpressionFormatMethods {
047
048        /**
049         * Convert all characters to lower case/
050         *
051         * e.g. "Firelfy" ➔ "firefly"
052         */
053        public static String lower(String self) {
054                return self.toLowerCase();
055        }
056
057        public static String lower(String self, String pattern) {
058                return StringUtilities.replaceAll(self, compile(pattern), (i, s) -> lower(s));
059        }
060
061        /**
062         * Convert all characters to upper case.
063         *
064         * e.g. "Firelfy" ➔ "FIREFLY"
065         */
066        public static String upper(String self) {
067                return self.toUpperCase();
068        }
069
070        public static String upper(String self, String pattern) {
071                return StringUtilities.replaceAll(self, compile(pattern), (i, s) -> upper(s));
072        }
073
074        /**
075         * Pad number patterns to length using the given character.
076         *
077         * e.g. "1x01" ➔ "01x001"
078         */
079        public static String pad(String self, int... length) {
080                if (length == null || length.length == 0) {
081                        throw new InvalidInputException("1 padding length is required");
082                }
083
084                return StringUtilities.replaceAll(self, DIGIT, (i, m) -> {
085                        return pad(m, length[i < length.length ? i : length.length - 1], "0");
086                });
087        }
088
089        public static String pad(Number self, int length) {
090                return pad(self.toString(), length, "0");
091        }
092
093        public static String pad(CharSequence self, int length, CharSequence padding) {
094                return StringGroovyMethods.padLeft(self, length, padding);
095        }
096
097        /**
098         * Round decimal number to precision.
099         *
100         * e.g. "3.14" ➔ "3.1"
101         */
102        public static Number round(Number self, int precision) {
103                return DefaultGroovyMethods.round(BigDecimal.valueOf(self.doubleValue()), precision);
104        }
105
106        /**
107         * Match pattern and return or unwind if pattern cannot be found.
108         */
109        public static String match(String self, String pattern) {
110                return match(self, pattern, -1);
111        }
112
113        public static String match(String self, String pattern, int matchGroup) {
114                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE).matcher(self);
115                if (matcher.find()) {
116                        return firstCapturingGroup(matcher, matchGroup);
117                }
118                throw new InvalidInputException("Pattern not found: " + pattern + ": " + self);
119        }
120
121        /**
122         * Match all occurrences of the given pattern or unwind if pattern cannot be found.
123         */
124        public static List<String> matchAll(String self, String pattern) {
125                return matchAll(self, pattern, -1);
126        }
127
128        public static List<String> matchAll(String self, String pattern, int matchGroup) {
129                List<String> matches = new ArrayList<String>();
130                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE).matcher(self);
131                while (matcher.find()) {
132                        matches.add(firstCapturingGroup(matcher, matchGroup));
133                }
134
135                if (matches.isEmpty()) {
136                        throw new InvalidInputException("Pattern not found: " + pattern + ": " + self);
137                }
138                return matches;
139        }
140
141        private static String firstCapturingGroup(Matcher self, int matchGroup) {
142                int g = matchGroup < 0 ? self.groupCount() > 0 ? 1 : 0 : matchGroup;
143
144                // return the entire match
145                if (g == 0) {
146                        return self.group();
147                }
148
149                // otherwise find first non-empty capturing group
150                return IntStream.rangeClosed(g, self.groupCount()).mapToObj(self::group).filter(Objects::nonNull).map(String::trim).filter(s -> s.length() > 0).findFirst().orElseThrow(() -> {
151                        return new InvalidInputException("Capturing group " + g + " not found");
152                });
153        }
154
155        public static String replaceAll(String self, String pattern) {
156                return compile(pattern).matcher(self).replaceAll("");
157        }
158
159        public static String removeAll(String self, String pattern) {
160                return compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE).matcher(self).replaceAll("").trim();
161        }
162
163        public static String remove(String self, String character, String... characters) {
164                return Stream.of(characters).reduce(self.replace(character, ""), (s, c) -> s.replace(c, ""));
165        }
166
167        /**
168         * Strip characters that aren't allowed on Windows from the given filename.
169         *
170         * e.g. "Sissi: The Young Empress" ➔ "Sissi The Young Empress"
171         */
172        public static String removeIllegalCharacters(String self) {
173                return FileUtilities.validateFileName(normalizeQuotationMarks(self));
174        }
175
176        /**
177         * Replace characters that aren't allowed on Windows from the given filename with similar-looking unicode equivalents
178         */
179        public static String replaceIllegalCharacters(String self) {
180                return org.codehaus.groovy.util.StringUtil.tr(self, "<>:\"/|?*\\", "﹤﹥꞉“⁄⼁?﹡∖");
181        }
182
183        /**
184         * Strip brackets and other clutter patterns.
185         * 
186         * e.g. [ONe]_Ano_Hana_01_(1280x720) ➔ Ano Hana 01
187         */
188        public static String clean(String self) {
189                return FileUtilities.validateFileName(MediaDetection.stripReleaseInfo(self, true));
190        }
191
192        /**
193         * Replace all spaces.
194         *
195         * e.g. "Doctor Who" ➔ "Doctor_Who"
196         */
197        public static String space(String self, String replacement) {
198                return normalizeSpace(self, replacement);
199        }
200
201        /**
202         * Replace all colons.
203         *
204         * e.g. "Sissi: The Young Empress" ➔ "Sissi - The Young Empress"
205         */
206        public static String colon(String self, String colon) {
207                return COLON.matcher(self).replaceAll(colon);
208        }
209
210        public static String colon(String self, String colon, String ratio) {
211                return COLON.matcher(RATIO.matcher(self).replaceAll(ratio)).replaceAll(colon);
212        }
213
214        /**
215         * Replace all slashes.
216         *
217         * e.g. "V_MPEG4/ISO/AVC" ➔ "V_MPEG4.ISO.AVC"
218         */
219        public static String slash(String self, String replacement) {
220                return SLASH.matcher(self).replaceAll(replacement);
221        }
222
223        /**
224         * Convert all initial characters to upper case.
225         *
226         * e.g. "The Day a new Demon was born" ➔ "The Day A New Demon Was Born"
227         */
228        public static String upperInitial(String self) {
229                return replaceHeadTail(self, String::toUpperCase, String::toString);
230        }
231
232        /**
233         * Convert all trailing characters to lower case.
234         *
235         * e.g. "Gundam SEED" ➔ "Gundam Seed"
236         */
237        public static String lowerTrail(String self) {
238                return replaceHeadTail(self, String::toString, String::toLowerCase);
239        }
240
241        private static String replaceHeadTail(String self, Function<String, String> head, Function<String, String> tail) {
242                // EXCEPT: I'm, It's, We're, I'll, etc
243                Matcher matcher = compile("\\b((?<!['`´])\\p{Alnum}|['`´](?!m|s|re|ll)\\p{Alnum}(?=\\p{Alnum}))(\\p{Alnum}*)\\b", UNICODE_CHARACTER_CLASS).matcher(self);
244
245                StringBuffer buffer = new StringBuffer();
246                while (matcher.find()) {
247                        matcher.appendReplacement(buffer, head.apply(matcher.group(1)) + tail.apply(matcher.group(2)));
248                }
249
250                return matcher.appendTail(buffer).toString();
251        }
252
253        /**
254         * Convert to sort name.
255         *
256         * e.g. "The Walking Dead" ➔ "Walking Dead"
257         */
258        public static String sortName(String self) {
259                return compile("^(The|A|An)\\s+", CASE_INSENSITIVE).matcher(self).replaceFirst("");
260        }
261
262        /**
263         * Convert to sort name with custom replacement pattern
264         * 
265         * e.g. "The Gabby Hayes Show (1956)" ➔ "Gabby Hayes Show, The (1956)"
266         */
267        public static String sortName(String self, String replacement) {
268                Matcher nameMatcher = compile("^(The|A|An)\\s+(.+)", CASE_INSENSITIVE).matcher(self);
269                Matcher yearMatcher = compile("\\s+[(]\\w+[)]$").matcher(self);
270                if (yearMatcher.find()) {
271                        nameMatcher = nameMatcher.region(0, yearMatcher.start());
272                }
273                if (nameMatcher.find()) {
274                        StringBuffer s = new StringBuffer();
275                        nameMatcher.appendReplacement(s, replacement);
276                        nameMatcher.appendTail(s);
277                        return s.toString().trim();
278                }
279                return self;
280        }
281
282        public static String sortInitial(String self) {
283                // use primary initial, ignore The XY, A XY, etc
284                return sortName(self).codePoints().mapToObj(c -> {
285                        if (Character.isDigit(c)) {
286                                return "0-9";
287                        }
288                        if (Character.isLetter(c)) {
289                                String character = new StringBuilder(2).appendCodePoint(c).toString();
290                                return ASCII.transform(character).substring(0, 1).toUpperCase();
291                        }
292                        return null;
293                }).filter(Objects::nonNull).findFirst().get();
294        }
295
296        /**
297         * Reduce first name to initials.
298         *
299         * e.g. "James Cameron" ➔ "J. Cameron"
300         */
301        public static String initialName(String self) {
302                String[] words = SPACE.split(self);
303                for (int i = 0; i < words.length - 1; i++) {
304                        words[i] = words[i].charAt(0) + ".";
305                }
306                return String.join(" ", words);
307        }
308
309        public static String truncate(String self, int limit) {
310                return truncateText(self, limit);
311        }
312
313        public static String truncate(String self, int hardLimit, String nonWordPattern) {
314                if (hardLimit >= self.length()) {
315                        return self;
316                }
317
318                int softLimit = 0;
319                Matcher matcher = compile(nonWordPattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
320                while (matcher.find()) {
321                        if (matcher.start() > hardLimit) {
322                                break;
323                        }
324                        softLimit = matcher.start();
325                }
326                return truncate(self, softLimit);
327        }
328
329        /**
330         * Match substring before the given pattern or return the original value.
331         */
332        public static String before(String self, String pattern) {
333                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
334
335                // pattern was found, return leading substring, else return original value
336                return matcher.find() ? self.substring(0, matcher.start()).trim() : self;
337        }
338
339        /**
340         * Match substring before the given pattern or return the original value.
341         */
342        public static String after(String self, String pattern) {
343                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
344
345                // pattern was found, return trailing substring, else return original value
346                return matcher.find() ? self.substring(matcher.end(), self.length()).trim() : self;
347        }
348
349        /**
350         * Find match in case-insensitive mode.
351         */
352        public static Matcher findMatch(String self, String pattern) {
353                if (pattern == null || pattern.isEmpty()) {
354                        return null;
355                }
356
357                return compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
358        }
359
360        /**
361         * Find match in between word boundaries in case-insensitive mode.
362         */
363        public static Matcher findWordMatch(String self, String pattern) {
364                if (pattern == null || pattern.isEmpty()) {
365                        return null;
366                }
367
368                return findMatch(self, "(?<!\\p{Alnum})(?:" + pattern + ")(?!\\p{Alnum})");
369        }
370
371        /**
372         * Match brackets pattern.
373         *
374         * e.g. "The IT Crowd (UK)" ➔ "UK"
375         */
376        public static List<String> matchBrackets(String self) {
377                return matchAll(self, "[\\(\\[\\{]([^\\(\\[\\{\\)\\]\\}]+)[\\)\\]\\}]", 1);
378        }
379
380        /**
381         * Remove brackets pattern.
382         *
383         * e.g. "The IT Crowd (UK)" ➔ "The IT Crowd"
384         */
385        public static String removeBrackets(String self) {
386                return removeAll(self, "[\\(\\[\\{]([^\\(\\[\\{\\)\\]\\}]*)[\\)\\]\\}]");
387        }
388
389        /**
390         * Strip trailing parenthesis.
391         *
392         * e.g. "The IT Crowd (UK)" ➔ "The IT Crowd"
393         */
394        public static String replaceTrailingBrackets(String self) {
395                return replaceTrailingBrackets(self, "");
396        }
397
398        /**
399         * Replace trailing parenthesis.
400         *
401         * e.g. "The IT Crowd (UK)" ➔ "The IT Crowd [UK]"
402         */
403        public static String replaceTrailingBrackets(String self, String replacement) {
404                return compile("\\s*[(]([^)]*)[)]$", UNICODE_CHARACTER_CLASS).matcher(self).replaceAll(replacement);
405        }
406
407        /**
408         * Strip trailing part number.
409         *
410         * e.g. "Today Is the Day (1)" ➔ "Today Is the Day"
411         */
412        public static String replacePart(String self) {
413                return replacePart(self, "");
414        }
415
416        /**
417         * Replace trailing part number.
418         *
419         * e.g. "Today Is the Day (1)" ➔ "Today Is the Day, Part 1"
420         */
421        public static String replacePart(String self, String replacement) {
422                // handle '(n)', '(Part n)' and ': Part n' like syntax
423                String[] patterns = new String[] { "\\s*[(](\\w{1,3})[)]$", "\\W+Part (\\w+)\\W*$" };
424
425                for (String pattern : patterns) {
426                        Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
427                        if (matcher.find()) {
428                                return matcher.replaceAll(replacement).trim();
429                        }
430                }
431
432                // no pattern matches, nothing to replace
433                return self;
434        }
435
436        /**
437         * Convert to acronym.
438         *
439         * e.g. "Deep Space 9" ➔ "DS9"
440         */
441        public static String acronym(String self) {
442                return compile("\\s|\\B\\p{Alnum}+", UNICODE_CHARACTER_CLASS).matcher(space(self, " ")).replaceAll("");
443        }
444
445        /**
446         * Replace numbers 1..12 with Roman numerals.
447         *
448         * e.g. "Star Wars: Episode 4" ➔ "Star Wars: Episode IV"
449         */
450        public static String roman(String self) {
451                TreeMap<Integer, String> numerals = new TreeMap<Integer, String>();
452                numerals.put(10, "X");
453                numerals.put(9, "IX");
454                numerals.put(5, "V");
455                numerals.put(4, "IV");
456                numerals.put(1, "I");
457
458                StringBuffer s = new StringBuffer();
459                Matcher m = compile("\\b\\d+\\b").matcher(self);
460                while (m.find()) {
461                        int n = Integer.parseInt(m.group());
462                        m.appendReplacement(s, n >= 1 && n <= 12 ? roman(n, numerals) : m.group());
463                }
464                return m.appendTail(s).toString();
465        }
466
467        public static String roman(Integer n, TreeMap<Integer, String> numerals) {
468                int l = numerals.floorKey(n);
469                if (n == l) {
470                        return numerals.get(n);
471                }
472                return numerals.get(l) + roman(n - l, numerals);
473        }
474
475        /**
476         * Apply any ICU script transliteration.
477         *
478         * e.g. "中国" ➔ "zhōng guó"
479         * 
480         * @see http://userguide.icu-project.org/transforms/general
481         */
482        public static String transliterate(String self, String transformIdentifier) {
483                return getTransliterator(transformIdentifier).transform(self);
484        }
485
486        /**
487         * Convert Unicode characters to ASCII.
488         *
489         * e.g. "カタカナ" ➔ "katakana"
490         */
491        public static String ascii(String self) {
492                // do nothing if the input is already in English
493                Pattern nonascii = compile("\\P{ASCII}+");
494                if (!nonascii.matcher(self).find()) {
495                        return self;
496                }
497
498                // Any-ASCII may introduce / into the file path (e.g. ½ => 1/2)
499                Pattern separator = compile("/|\\\\");
500                return separator.splitAsStream(self).map(s -> {
501                        s = ASCII.transform(asciiQuotes(s));
502                        s = nonascii.matcher(s).replaceAll(" ");
503                        s = separator.matcher(s).replaceAll(" ");
504                        return s.trim();
505                }).collect(Collectors.joining("/"));
506        }
507
508        public static String asciiQuotes(String self) {
509                return normalizeQuotationMarks(self);
510        }
511
512        public static boolean isLatin(String self) {
513                return LATIN.matcher(self).matches();
514        }
515
516        public static List<String> getGraphemeClusters(String self) {
517                return tokenizeGraphemeClusters(self);
518        }
519
520        /**
521         * Apply replacement mappings.
522         *
523         * e.g. replace(ä:'ae', ö:'oe', ü:'ue')
524         */
525        public static String replace(String self, Map<?, ?> replacer) {
526                for (Entry<?, ?> m : replacer.entrySet()) {
527                        for (Object key : DefaultGroovyMethods.flatten(asList(m.getKey()))) {
528                                Pattern pattern = key instanceof Pattern ? (Pattern) key : compile(quote(key.toString()));
529                                if (m.getValue() instanceof Closure) {
530                                        self = StringGroovyMethods.replaceAll(self, pattern, (Closure) m.getValue());
531                                } else {
532                                        self = StringGroovyMethods.replaceAll(self, pattern, m.getValue().toString());
533                                }
534                        }
535                }
536                return self;
537        }
538
539        /**
540         * Apply replacement mappings.
541         *
542         * e.g. replace('Directors Cut':'DC')
543         */
544        public static Collection<String> replace(Collection<?> self, Map<String, String> replacer) {
545                return self.stream().map(Objects::toString).map(v -> replacer.getOrDefault(v, v)).filter(Objects::nonNull).collect(Collectors.toList());
546        }
547
548        /**
549         * Find matching pattern and return mapped value.
550         * 
551         * e.g. az.match('[a-f]': '/volume1', '[g-x]': '/volume2') ?: '/volume3'
552         */
553        public static Object match(String self, Map<?, ?> matcher) {
554                for (Entry<?, ?> m : matcher.entrySet()) {
555                        Pattern p = m.getKey() instanceof Pattern ? (Pattern) m.getKey() : compile(m.getKey().toString(), CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE);
556                        if (p.matcher(self).find()) {
557                                return m.getValue();
558                        }
559                }
560                return null;
561        }
562
563        public static Object match(Number self, Map<?, ?> matcher) {
564                return match(self.toString(), matcher);
565        }
566
567        public static Object match(Collection<?> self, Map<?, ?> matcher) {
568                for (Entry<?, ?> m : matcher.entrySet()) {
569                        Pattern p = m.getKey() instanceof Pattern ? (Pattern) m.getKey() : compile(m.getKey().toString(), CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE);
570                        for (Object o : self) {
571                                if (o != null) {
572                                        if (p.matcher(o.toString()).find()) {
573                                                return m.getValue();
574                                        }
575                                }
576                        }
577                }
578                return null;
579        }
580
581        public static String joining(Collection<?> self, String delimiter) {
582                return joining(self, delimiter, "", "");
583        }
584
585        public static String joining(Collection<?> self, String delimiter, String prefix, String suffix) {
586                List<String> list = DefaultGroovyMethods.flatten(self).stream().filter(Objects::nonNull).map(Objects::toString).map(s -> {
587                        return FileUtilities.replacePathSeparators(s, "");
588                }).filter(s -> {
589                        return !s.isEmpty();
590                }).collect(Collectors.toList());
591
592                if (list.isEmpty()) {
593                        throw new InvalidInputException("Collection did not yield any values: " + self);
594                }
595
596                return list.stream().collect(Collectors.joining(delimiter, prefix, suffix));
597        }
598
599        public static String joiningDistinct(Collection<?> self, String delimiter, Closure<?>... mapper) {
600                return joiningDistinct(self, delimiter, "", "", mapper);
601        }
602
603        public static String joiningDistinct(Collection<?> self, String delimiter, String prefix, String suffix, Closure<?>... mapper) {
604                List<?> list = DefaultGroovyMethods.flatten(self).stream().filter(Objects::nonNull).flatMap(v -> {
605                        return mapper.length == 0 ? Stream.of(v) : Stream.of(mapper).map(m -> m.call(v));
606                }).filter(Objects::nonNull).map(Objects::toString).distinct().collect(Collectors.toList());
607
608                return joining(list, delimiter, prefix, suffix);
609        }
610
611        public static List<?> bounds(Iterable<?> self) {
612                return Stream.of(DefaultGroovyMethods.min(self), DefaultGroovyMethods.max(self)).filter(Objects::nonNull).distinct().collect(Collectors.toList());
613        }
614
615        /**
616         * Unwind if an object does not satisfy the given predicate.
617         *
618         * e.g. (0..9)*.check{it < 10}.sum()
619         */
620        public static Object check(Object self, Closure<?> c) {
621                if (DefaultTypeTransformation.castToBoolean(c.call(self))) {
622                        return self;
623                }
624                throw new InvalidInputException("Object failed check: " + self);
625        }
626
627        /**
628         * Add values to the file name, after the file name, but before the subtitle language suffix and file extension.
629         *
630         * e.g. "Avatar (2009).mp4" ➔ "Avatar (2009) [720p].mp4"
631         */
632        public static StructuredFile derive(StructuredFile self, Object tag, Object... tags) {
633                return self.tag(component(null, tag, tags));
634        }
635
636        /**
637         * Add a value to the movie / series folder name.
638         * 
639         * e.g. Avatar (2009)/Avatar (2009) ➔ Avatar (2009) [TMDB-19995]/Avatar (2009)
640         */
641        public static StructuredFile deriveFolder(StructuredFile self, Object tag, Object... tags) {
642                return self.collection(component(null, tag, tags));
643        }
644
645        /**
646         * Add values to the file name, after the file name, but before the subtitle language suffix and file extension.
647         * 
648         * e.g. plex % {" by $director"} % {" [$vf, $vc, $ac]"}
649         */
650        public static StructuredFile mod(StructuredFile self, Object tag) {
651                return derive(self, tag);
652        }
653
654        /**
655         * Add a value to the movie / series folder name.
656         * 
657         * e.g. plex * " [TMDB-$id]"
658         */
659        public static StructuredFile multiply(StructuredFile self, Object tag) {
660                return deriveFolder(self, tag);
661        }
662
663        /**
664         * Add a value to the file name and the movie / series folder name.
665         * 
666         * e.g. plex ** " [TMDB-$id]"
667         */
668        public static StructuredFile power(StructuredFile self, Object tag) {
669                return deriveFolder(derive(self, tag), tag);
670        }
671
672        /**
673         * Nullify the Movies / TV Series folder level.
674         * 
675         * e.g. ~plex
676         */
677        public static StructuredFile bitwiseNegate(StructuredFile self) {
678                return self.category(null);
679        }
680
681        /**
682         * Add a value to the season folder level.
683         * 
684         * e.g. plex << { sy.bounds().joining('-', ' [', ']') }
685         */
686        public static StructuredFile leftShift(StructuredFile self, Object tag) {
687                return self.group(component(null, tag));
688        }
689
690        /**
691         * Add a value to the A-Z folder level.
692         * 
693         * e.g. plex >> { az }
694         */
695        public static StructuredFile rightShift(StructuredFile self, Object tag) {
696                return self.library(component(null, tag));
697        }
698
699        /**
700         * Replace subtitle language suffix.
701         * 
702         * e.g. plex ^ {'.'+lang.ISO2}
703         */
704        public static StructuredFile xor(StructuredFile self, Object tag) {
705                return self.suffix(null).suffix(component(null, tag));
706        }
707
708        /*
709         * File extensions
710         */
711
712        public static File ascii(File self) {
713                return new File(ascii(self.getPath()));
714        }
715
716        public static long getDiskSpace(File self) {
717                return FileUtilities.getDiskSpace(self);
718        }
719
720        public static File getRoot(File self) {
721                return FileUtilities.listPath(self).get(0);
722        }
723
724        public static File getTail(File self) {
725                return FileUtilities.getRelativePathTail(self, FileUtilities.listPath(self).size() - 1);
726        }
727
728        public static File head(File self, int n) {
729                if (n < 1) {
730                        return null;
731                }
732                List<File> path = FileUtilities.listPath(self);
733                return path.size() < n ? self : path.get(n - 1);
734        }
735
736        public static File tail(File self, int n) {
737                if (n < 1) {
738                        return null;
739                }
740                List<File> path = FileUtilities.listPath(self);
741                return path.size() < n ? self : path.stream().skip(path.size() - n).reduce(null, (parent, child) -> new File(parent, child.getName()));
742        }
743
744        public static List<File> listPath(File self) {
745                return FileUtilities.listPath(self);
746        }
747
748        public static List<File> listPath(File self, int tailSize) {
749                return FileUtilities.listPath(FileUtilities.getRelativePathTail(self, tailSize));
750        }
751
752        public static File getRelativePathTail(File self, int tailSize) {
753                return FileUtilities.getRelativePathTail(self, tailSize);
754        }
755
756        public static LocalDateTime toDate(Long self) {
757                return LocalDateTime.ofInstant(Instant.ofEpochMilli(self), ZoneId.systemDefault());
758        }
759
760        public static LocalDateTime toDate(Long self, String zone) {
761                return LocalDateTime.ofInstant(Instant.ofEpochMilli(self), ZoneId.of(zone, ZoneId.SHORT_IDS));
762        }
763
764        public static File toFile(String self) {
765                if (self == null || self.isEmpty()) {
766                        return null;
767                }
768                return new File(self);
769        }
770
771        public static File toFile(String self, String parent) {
772                if (self == null || self.isEmpty()) {
773                        return null;
774                }
775                File file = new File(self);
776                if (file.isAbsolute()) {
777                        return file;
778                }
779                return new File(parent, self);
780        }
781
782        public static Locale toLocale(String self) {
783                return Locale.forLanguageTag(self);
784        }
785
786        /*
787         * Date extensions
788         */
789
790        public static String format(Temporal self, String pattern) {
791                return format(self, pattern, Locale.US);
792        }
793
794        public static String format(Temporal self, String pattern, Locale locale) {
795                return DateTimeUtilities.format(self, pattern, locale);
796        }
797
798        public static String format(TemporalAmount self, String pattern) {
799                return format(self, pattern, Locale.US);
800        }
801
802        public static String format(TemporalAmount self, String pattern, Locale locale) {
803                return DateTimeUtilities.format(self, pattern, locale);
804        }
805
806        public static String format(Date self, String format) {
807                return format(self, format, Locale.US);
808        }
809
810        public static String format(Date self, String format, Locale locale) {
811                return DateTimeUtilities.format(self, format, locale);
812        }
813
814        public static ZonedDateTime zone(Temporal self, String zone) {
815                return DateTimeUtilities.toDateTime(self).withZoneSameInstant(ZoneId.of(zone));
816        }
817
818        public static Date parseDate(String self, String format) {
819                return parseDate(self, format, Locale.US);
820        }
821
822        public static Date parseDate(String self, String format, Locale locale) {
823                return DateTimeUtilities.toDate(DateTimeUtilities.parseDateTime(self, format, locale, ZoneId.systemDefault()));
824        }
825
826        public static Date parseDate(String self) {
827                return DateTimeUtilities.toDate(DateTimeUtilities.parseDateTime(self, ZoneId.systemDefault()));
828        }
829
830        public static int compareTo(Temporal self, String date) {
831                return DateTimeUtilities.toDateTime(self).toInstant().compareTo(DateTimeUtilities.parseDateTime(date, ZoneId.systemDefault()));
832        }
833
834        /*
835         * DSL extensions
836         */
837
838        public static File plus(File self, String path) {
839                return new File(self.getPath().concat(path));
840        }
841
842        public static File div(File self, String path) {
843                return new File(self, path);
844        }
845
846        public static File div(String self, String path) {
847                return new File(self, path);
848        }
849
850        public static File div(File self, File path) {
851                return new File(self, path.getPath());
852        }
853
854        public static File div(String self, File path) {
855                return new File(self, path.getPath());
856        }
857
858        public static File div(File self, List<Object> path) {
859                for (Object level : path) {
860                        self = div(self, FileUtilities.replacePathSeparators(level.toString(), ""));
861                }
862                return self;
863        }
864
865        public static File mod(File self, Object suffix) {
866                String name = FileUtilities.getNameWithoutExtension(self.getName());
867                String extension = self.getName().substring(name.length());
868
869                return new File(self.getParentFile(), component(null, name, suffix, extension));
870        }
871
872        public static String plus(String self, Closure closure) {
873                return concat(null, self, closure);
874        }
875
876        public static ReverseComparable negative(Comparable self) {
877                return new ReverseComparable(self);
878        }
879
880        private ExpressionFormatMethods() {
881                throw new UnsupportedOperationException();
882        }
883
884}