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