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}