001package net.filebot.format;
002
003import static java.util.Arrays.*;
004import static java.util.regex.Pattern.*;
005import static java.util.stream.Collectors.*;
006import static net.filebot.MediaTypes.*;
007import static net.filebot.format.ExpressionFormatFunctions.*;
008import static net.filebot.media.MediaDetection.*;
009import static net.filebot.similarity.Normalization.*;
010import static net.filebot.util.RegularExpressions.*;
011
012import java.io.File;
013import java.io.IOException;
014import java.nio.file.Files;
015import java.nio.file.attribute.BasicFileAttributeView;
016import java.nio.file.attribute.BasicFileAttributes;
017import java.text.Normalizer;
018import java.text.ParseException;
019import java.text.SimpleDateFormat;
020import java.time.Instant;
021import java.time.LocalDateTime;
022import java.time.LocalTime;
023import java.time.ZoneOffset;
024import java.time.format.DateTimeFormatter;
025import java.time.temporal.Temporal;
026import java.time.temporal.TemporalAmount;
027import java.util.ArrayList;
028import java.util.Collection;
029import java.util.Date;
030import java.util.List;
031import java.util.Locale;
032import java.util.Map;
033import java.util.Map.Entry;
034import java.util.Objects;
035import java.util.TreeMap;
036import java.util.function.Function;
037import java.util.regex.Matcher;
038import java.util.regex.Pattern;
039import java.util.stream.IntStream;
040import java.util.stream.Stream;
041
042import org.codehaus.groovy.runtime.DefaultGroovyMethods;
043import org.codehaus.groovy.runtime.StringGroovyMethods;
044import org.codehaus.groovy.runtime.typehandling.DefaultTypeTransformation;
045
046import com.ibm.icu.text.Transliterator;
047
048import groovy.lang.Closure;
049import net.filebot.util.FileUtilities;
050
051public class ExpressionFormatMethods {
052
053        /**
054         * Convert all characters to lower case/
055         *
056         * e.g. "Firelfy" ➔ "firefly"
057         */
058        public static String lower(String self) {
059                return self.toLowerCase();
060        }
061
062        /**
063         * Convert all characters to upper case.
064         *
065         * e.g. "Firelfy" ➔ "FIREFLY"
066         */
067        public static String upper(String self) {
068                return self.toUpperCase();
069        }
070
071        /**
072         * Pad to length using the given character.
073         *
074         * e.g. "1" ➔ "01"
075         */
076        public static String pad(String self, int length) {
077                return pad(self, length, "0");
078        }
079
080        public static String pad(Number self, int length) {
081                return pad(self.toString(), length, "0");
082        }
083
084        public static String pad(CharSequence self, int length, CharSequence padding) {
085                return StringGroovyMethods.padLeft(self, length, padding);
086        }
087
088        /**
089         * Round decimal number to precision.
090         *
091         * e.g. "3.14" ➔ "3.1"
092         */
093        public static double round(Number self, int precision) {
094                return DefaultGroovyMethods.round(self.doubleValue(), precision);
095        }
096
097        /**
098         * Match pattern and return or unwind if pattern cannot be found.
099         */
100        public static String match(String self, String pattern) throws Exception {
101                return match(self, pattern, -1);
102        }
103
104        public static String match(String self, String pattern, int matchGroup) throws Exception {
105                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE).matcher(self);
106                if (matcher.find()) {
107                        return firstCapturingGroup(matcher, matchGroup);
108                } else {
109                        throw new Exception("Pattern not found: " + self);
110                }
111        }
112
113        /**
114         * Match all occurrences of the given pattern or unwind if pattern cannot be found.
115         */
116        public static List<String> matchAll(String self, String pattern) throws Exception {
117                return matchAll(self, pattern, -1);
118        }
119
120        public static List<String> matchAll(String self, String pattern, int matchGroup) throws Exception {
121                List<String> matches = new ArrayList<String>();
122                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE).matcher(self);
123                while (matcher.find()) {
124                        matches.add(firstCapturingGroup(matcher, matchGroup));
125                }
126
127                if (matches.isEmpty()) {
128                        throw new Exception("Pattern not found: " + self);
129                }
130                return matches;
131        }
132
133        private static String firstCapturingGroup(Matcher self, int matchGroup) throws Exception {
134                int g = matchGroup < 0 ? self.groupCount() > 0 ? 1 : 0 : matchGroup;
135
136                // return the entire match
137                if (g == 0) {
138                        return self.group();
139                }
140
141                // otherwise find first non-empty capturing group
142                return IntStream.rangeClosed(g, self.groupCount()).mapToObj(self::group).filter(Objects::nonNull).map(String::trim).filter(s -> s.length() > 0).findFirst().orElseThrow(() -> {
143                        return new Exception(String.format("Capturing group %d not found", g));
144                });
145        }
146
147        public static String replaceAll(String self, String pattern) {
148                return compile(pattern).matcher(self).replaceAll("");
149        }
150
151        public static String removeAll(String self, String pattern) {
152                return compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE).matcher(self).replaceAll("").trim();
153        }
154
155        /**
156         * Strip characters that aren't allowed on Windows from the given filename.
157         *
158         * e.g. "Sissi: The Young Empress" ➔ "Sissi The Young Empress"
159         */
160        public static String removeIllegalCharacters(String self) {
161                return FileUtilities.validateFileName(normalizeQuotationMarks(self));
162        }
163
164        /**
165         * Replace all spaces.
166         *
167         * e.g. "Doctor Who" ➔ "Doctor_Who"
168         */
169        public static String space(String self, String replacement) {
170                return normalizeSpace(self, replacement);
171        }
172
173        /**
174         * Replace all colons.
175         *
176         * e.g. "Sissi: The Young Empress" ➔ "Sissi - The Young Empress"
177         */
178        public static String colon(String self, String colon) {
179                return COLON.matcher(self).replaceAll(colon);
180        }
181
182        public static String colon(String self, String ratio, String colon) {
183                return COLON.matcher(RATIO.matcher(self).replaceAll(ratio)).replaceAll(colon);
184        }
185
186        /**
187         * Replace all slashes.
188         *
189         * e.g. "V_MPEG4/ISO/AVC" ➔ "V_MPEG4.ISO.AVC"
190         */
191        public static String slash(String self, String replacement) {
192                return SLASH.matcher(self).replaceAll(replacement);
193        }
194
195        /**
196         * Convert all initial characters to upper case.
197         *
198         * e.g. "The Day a new Demon was born" ➔ "The Day A New Demon Was Born"
199         */
200        public static String upperInitial(String self) {
201                return replaceHeadTail(self, String::toUpperCase, String::toString);
202        }
203
204        /**
205         * Convert all trailing characters to lower case.
206         *
207         * e.g. "Gundam SEED" ➔ "Gundam Seed"
208         */
209        public static String lowerTrail(String self) {
210                return replaceHeadTail(self, String::toString, String::toLowerCase);
211        }
212
213        private static String replaceHeadTail(String self, Function<String, String> head, Function<String, String> tail) {
214                Matcher matcher = compile("\\b(['`´]|\\p{Alnum})(\\p{Alnum}*)\\b", UNICODE_CHARACTER_CLASS).matcher(self);
215
216                StringBuffer buffer = new StringBuffer();
217                while (matcher.find()) {
218                        matcher.appendReplacement(buffer, head.apply(matcher.group(1)) + tail.apply(matcher.group(2)));
219                }
220
221                return matcher.appendTail(buffer).toString();
222        }
223
224        /**
225         * Convert to sort name.
226         *
227         * e.g. "The Walking Dead" ➔ "Walking Dead"
228         */
229        public static String sortName(String self) {
230                return sortName(self, "$2");
231        }
232
233        public static String sortName(String self, String replacement) {
234                return compile("^(The|A|An)\\s(.+)", CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self).replaceFirst(replacement).trim();
235        }
236
237        public static String sortInitial(String self) {
238                // use primary initial, ignore The XY, A XY, etc
239                char c = ascii(sortName(self)).charAt(0);
240
241                if (Character.isDigit(c)) {
242                        return "0-9";
243                } else if (Character.isLetter(c)) {
244                        return String.valueOf(c).toUpperCase();
245                } else {
246                        return null;
247                }
248        }
249
250        /**
251         * Reduce first name to initials.
252         *
253         * e.g. "James Cameron" ➔ "J. Cameron"
254         */
255        public static String initialName(String self) {
256                String[] words = SPACE.split(self);
257                for (int i = 0; i < words.length - 1; i++) {
258                        words[i] = words[i].charAt(0) + ".";
259                }
260                return String.join(" ", words);
261        }
262
263        public static String truncate(String self, int limit) {
264                if (limit >= self.length())
265                        return self;
266
267                return self.substring(0, limit);
268        }
269
270        public static String truncate(String self, int hardLimit, String nonWordPattern) {
271                if (hardLimit >= self.length())
272                        return self;
273
274                int softLimit = 0;
275                Matcher matcher = compile(nonWordPattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
276                while (matcher.find()) {
277                        if (matcher.start() > hardLimit) {
278                                break;
279                        }
280                        softLimit = matcher.start();
281                }
282                return truncate(self, softLimit);
283        }
284
285        /**
286         * Match substring before the given pattern or return the original value.
287         */
288        public static String before(String self, String pattern) {
289                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
290
291                // pattern was found, return leading substring, else return original value
292                return matcher.find() ? self.substring(0, matcher.start()).trim() : self;
293        }
294
295        /**
296         * Match substring before the given pattern or return the original value.
297         */
298        public static String after(String self, String pattern) {
299                Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
300
301                // pattern was found, return trailing substring, else return original value
302                return matcher.find() ? self.substring(matcher.end(), self.length()).trim() : self;
303        }
304
305        /**
306         * Find match in case-insensitive mode.
307         */
308        public static boolean findMatch(String self, String pattern) {
309                if (pattern == null || pattern.isEmpty())
310                        return false;
311
312                return compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self).find();
313        }
314
315        /**
316         * Find match in between word boundaries in case-insensitive mode.
317         */
318        public static boolean findWordMatch(String self, String pattern) {
319                if (pattern == null || pattern.isEmpty())
320                        return false;
321
322                return findMatch(self, "\\b(" + pattern + ")\\b");
323        }
324
325        /**
326         * Replace trailing parenthesis.
327         *
328         * e.g. "The IT Crowd (UK)" ➔ "The IT Crowd"
329         */
330        public static String replaceTrailingBrackets(String self) {
331                return replaceTrailingBrackets(self, "");
332        }
333
334        public static String replaceTrailingBrackets(String self, String replacement) {
335                return compile("\\s*[(]([^)]*)[)]$", UNICODE_CHARACTER_CLASS).matcher(self).replaceAll(replacement);
336        }
337
338        /**
339         * Replace trailing part number.
340         *
341         * e.g. "Today Is the Day (1)" ➔ "Today Is the Day, Part 1"
342         */
343        public static String replacePart(String self) {
344                return replacePart(self, "");
345        }
346
347        public static String replacePart(String self, String replacement) {
348                // handle '(n)', '(Part n)' and ': Part n' like syntax
349                String[] patterns = new String[] { "\\s*[(](\\w{1,3})[)]$", "\\W+Part (\\w+)\\W*$" };
350
351                for (String pattern : patterns) {
352                        Matcher matcher = compile(pattern, CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS).matcher(self);
353                        if (matcher.find()) {
354                                return matcher.replaceAll(replacement).trim();
355                        }
356                }
357
358                // no pattern matches, nothing to replace
359                return self;
360        }
361
362        /**
363         * Convert to acronym.
364         *
365         * e.g. "Deep Space 9" ➔ "DS9"
366         */
367        public static String acronym(String self) {
368                return compile("\\s|\\B\\p{Alnum}+", UNICODE_CHARACTER_CLASS).matcher(space(self, " ")).replaceAll("");
369        }
370
371        /**
372         * Replace numbers 1..12 with Roman numerals.
373         *
374         * e.g. "Star Wars: Episode 4" ➔ "Star Wars: Episode IV"
375         */
376        public static String roman(String self) {
377                TreeMap<Integer, String> numerals = new TreeMap<Integer, String>();
378                numerals.put(10, "X");
379                numerals.put(9, "IX");
380                numerals.put(5, "V");
381                numerals.put(4, "IV");
382                numerals.put(1, "I");
383
384                StringBuffer s = new StringBuffer();
385                Matcher m = compile("\\b\\d+\\b").matcher(self);
386                while (m.find()) {
387                        int n = Integer.parseInt(m.group());
388                        m.appendReplacement(s, n >= 1 && n <= 12 ? roman(n, numerals) : m.group());
389                }
390                return m.appendTail(s).toString();
391        }
392
393        public static String roman(Integer n, TreeMap<Integer, String> numerals) {
394                int l = numerals.floorKey(n);
395                if (n == l) {
396                        return numerals.get(n);
397                }
398                return numerals.get(l) + roman(n - l, numerals);
399        }
400
401        /**
402         * Apply any ICU script transliteration.
403         *
404         * e.g. "中国" ➔ "zhōng guó"
405         * 
406         * @see http://userguide.icu-project.org/transforms/general
407         */
408        public static String transliterate(String self, String transformIdentifier) {
409                return Transliterator.getInstance(transformIdentifier).transform(self);
410        }
411
412        /**
413         * Convert Unicode characters to ASCII.
414         *
415         * e.g. "カタカナ" ➔ "katakana"
416         */
417        public static String ascii(String self) {
418                return ascii(self, " ");
419        }
420
421        public static String ascii(String self, String fallback) {
422                return Transliterator.getInstance("Any-Latin;Latin-ASCII;[:Diacritic:]remove").transform(asciiQuotes(self)).replaceAll("\\P{ASCII}+", fallback).trim();
423        }
424
425        public static String asciiQuotes(String self) {
426                return normalizeQuotationMarks(self);
427        }
428
429        public static boolean isLatin(String self) {
430                return Normalizer.normalize(self, Normalizer.Form.NFD).replaceAll("\\p{InCombiningDiacriticalMarks}", "").matches("\\p{InBasicLatin}+");
431        }
432
433        /**
434         * Apply replacement mappings.
435         *
436         * e.g. replace(ä:'ae', ö:'oe', ü:'ue')
437         */
438        public static String replace(String self, Map<?, ?> replacer) {
439                // the first two parameters are required, the rest of the parameter sequence is optional
440                for (Entry<?, ?> it : replacer.entrySet()) {
441                        if (it.getKey() instanceof Pattern) {
442                                self = ((Pattern) it.getKey()).matcher(self).replaceAll(it.getValue().toString());
443                        } else {
444                                self = self.replace(it.getKey().toString(), it.getValue().toString());
445                        }
446                }
447                return self;
448        }
449
450        /**
451         * Find matching pattern and return mapped value.
452         * 
453         * e.g. az.match('[a-f]': '/volume1', '[g-x]': '/volume2') ?: '/volume3'
454         */
455        public static Object match(String self, Map<?, ?> matcher) {
456                // the first two parameters are required, the rest of the parameter sequence is optional
457                for (Entry<?, ?> it : matcher.entrySet()) {
458                        Pattern p = it.getKey() instanceof Pattern ? (Pattern) it.getKey() : Pattern.compile(it.getKey().toString(), CASE_INSENSITIVE | UNICODE_CHARACTER_CLASS | MULTILINE);
459                        if (p.matcher(self).find()) {
460                                return it.getValue();
461                        }
462                }
463                return null;
464        }
465
466        public static String joining(Collection<?> self, String delimiter) throws Exception {
467                String[] list = self.stream().filter(Objects::nonNull).map(Objects::toString).filter(s -> !s.isEmpty()).toArray(String[]::new);
468                if (list.length > 0) {
469                        return String.join(delimiter, list);
470                }
471
472                throw new Exception("Collection did not yield any values: " + self);
473        }
474
475        public static String joiningDistinct(Collection<?> self, String delimiter, Closure<?>... mapper) throws Exception {
476                Stream<?> stream = self.stream().filter(Objects::nonNull);
477
478                // apply custom mappers if any
479                if (mapper.length > 0) {
480                        stream = stream.flatMap(v -> stream(mapper).map(m -> m.call(v)).filter(Objects::nonNull));
481                }
482
483                // sort unique
484                String[] list = stream.map(Objects::toString).filter(s -> !s.isEmpty()).distinct().sorted().toArray(String[]::new);
485                if (list.length > 0) {
486                        return String.join(delimiter, list);
487                }
488
489                throw new Exception("Collection did not yield any values: " + self);
490        }
491
492        public static List<?> bounds(Iterable<?> self) {
493                return Stream.of(DefaultGroovyMethods.min(self), DefaultGroovyMethods.max(self)).filter(Objects::nonNull).distinct().collect(toList());
494        }
495
496        /**
497         * Unwind if an object does not satisfy the given predicate
498         *
499         * e.g. (0..9)*.check{it < 10}.sum()
500         */
501        public static Object check(Object self, Closure<?> c) throws Exception {
502                if (DefaultTypeTransformation.castToBoolean(c.call(self))) {
503                        return self;
504                }
505
506                throw new Exception("Object failed check: " + self);
507        }
508
509        /**
510         * Add values to the filename.
511         *
512         * e.g. "Avatar (2009).mp4" ➔ "Avatar (2009) [720p].mp4"
513         */
514        public static File derive(File self, Object tag, Object... tagN) {
515                // e.g. plex.derive{" by $director"}{" [$vc, $ac]"}
516                String name = FileUtilities.getName(self);
517                String extension = self.getName().substring(name.length());
518
519                // e.g. Avatar (2009).eng.srt => Avatar (2009) 1080p.eng.srt
520                if (SUBTITLE_FILES.accept(self)) {
521                        Matcher nameMatcher = releaseInfo.getSubtitleLanguageTagPattern().matcher(name);
522                        if (nameMatcher.find()) {
523                                extension = name.substring(nameMatcher.start() - 1) + extension;
524                                name = name.substring(0, nameMatcher.start() - 1);
525                        }
526                }
527
528                return new File(self.getParentFile(), concat(null, name, slash(concat(null, tag, null, tagN), ""), extension));
529        }
530
531        /**
532         * File utilities
533         */
534
535        public static long getDiskSpace(File self) {
536                List<File> list = FileUtilities.listPath(self);
537                for (int i = list.size() - 1; i >= 0; i--) {
538                        if (list.get(i).exists()) {
539                                long usableSpace = list.get(i).getUsableSpace();
540                                if (usableSpace > 0) {
541                                        return usableSpace;
542                                }
543                        }
544                }
545                return 0;
546        }
547
548        public static long getCreationDate(File self) throws IOException {
549                BasicFileAttributes attr = Files.getFileAttributeView(self.toPath(), BasicFileAttributeView.class).readAttributes();
550                long creationDate = attr.creationTime().toMillis();
551                if (creationDate > 0) {
552                        return creationDate;
553                }
554                return attr.lastModifiedTime().toMillis();
555        }
556
557        public static File getRoot(File self) {
558                return FileUtilities.listPath(self).get(0);
559        }
560
561        public static File getTail(File self) {
562                return FileUtilities.getRelativePathTail(self, FileUtilities.listPath(self).size() - 1);
563        }
564
565        public static List<File> listPath(File self) {
566                return FileUtilities.listPath(self);
567        }
568
569        public static List<File> listPath(File self, int tailSize) {
570                return FileUtilities.listPath(FileUtilities.getRelativePathTail(self, tailSize));
571        }
572
573        public static File getRelativePathTail(File self, int tailSize) {
574                return FileUtilities.getRelativePathTail(self, tailSize);
575        }
576
577        public static LocalDateTime toDate(Long self) {
578                return LocalDateTime.ofInstant(Instant.ofEpochMilli(self), ZoneOffset.systemDefault());
579        }
580
581        public static File toFile(String self) {
582                if (self == null || self.isEmpty()) {
583                        return null;
584                }
585                return new File(self);
586        }
587
588        public static File toFile(String self, String parent) {
589                if (self == null || self.isEmpty()) {
590                        return null;
591                }
592                File file = new File(self);
593                if (file.isAbsolute()) {
594                        return file;
595                }
596                return new File(parent, self);
597        }
598
599        public static Locale toLocale(String self) {
600                return Locale.forLanguageTag(self);
601        }
602
603        /**
604         * Date utilities
605         */
606        public static String format(Temporal self, String pattern) {
607                return DateTimeFormatter.ofPattern(pattern).format(self);
608        }
609
610        public static String format(TemporalAmount self, String pattern) {
611                return DateTimeFormatter.ofPattern(pattern).format(LocalTime.MIDNIGHT.plus(self));
612        }
613
614        public static String format(Date self, String format) {
615                return new SimpleDateFormat(format).format(self);
616        }
617
618        public static Date parseDate(String self, String format) throws ParseException {
619                return new SimpleDateFormat(format).parse(self);
620        }
621
622        /**
623         * DSL utilities
624         */
625        public static File plus(File self, String path) {
626                return new File(self.getPath().concat(path));
627        }
628
629        public static File div(File self, String path) {
630                return new File(self, path);
631        }
632
633        public static File div(String self, String path) {
634                return new File(self, path);
635        }
636
637        public static File div(File self, File path) {
638                return new File(self, path.getPath());
639        }
640
641        public static File div(String self, File path) {
642                return new File(self, path.getPath());
643        }
644
645        public static String negative(String self) {
646                return '-' + self;
647        }
648
649        public static String plus(String self, Closure closure) {
650                Object value = call(null, closure);
651                return value == null ? self : self + value;
652        }
653
654        private ExpressionFormatMethods() {
655                throw new UnsupportedOperationException();
656        }
657
658}