1 package org.unicode.cldr.tool; 2 3 import java.io.File; 4 import java.io.FileNotFoundException; 5 import java.io.IOException; 6 import java.io.PrintWriter; 7 import java.io.UncheckedIOException; 8 import java.util.Arrays; 9 import java.util.Collection; 10 import java.util.HashSet; 11 import java.util.LinkedHashSet; 12 import java.util.Map; 13 import java.util.Map.Entry; 14 import java.util.Objects; 15 import java.util.Set; 16 import java.util.TreeSet; 17 import java.util.regex.Matcher; 18 import java.util.regex.Pattern; 19 20 import com.google.common.collect.HashMultimap; 21 import com.google.common.collect.ImmutableSet; 22 import com.google.common.collect.Multimap; 23 import com.google.common.collect.Sets; 24 import com.google.common.collect.TreeMultimap; 25 import com.google.common.io.Files; 26 import com.ibm.icu.util.Output; 27 28 import org.unicode.cldr.tool.Option.Options; 29 import org.unicode.cldr.tool.Option.Params; 30 import org.unicode.cldr.util.CLDRConfig; 31 import org.unicode.cldr.util.CLDRFile; 32 import org.unicode.cldr.util.CLDRPaths; 33 import org.unicode.cldr.util.CldrUtility; 34 import org.unicode.cldr.util.DtdType; 35 import org.unicode.cldr.util.Factory; 36 import org.unicode.cldr.util.Level; 37 import org.unicode.cldr.util.LocaleIDParser; 38 import org.unicode.cldr.util.LogicalGrouping; 39 import org.unicode.cldr.util.Pair; 40 import org.unicode.cldr.util.SupplementalDataInfo; 41 import org.unicode.cldr.util.XMLSource; 42 import org.unicode.cldr.util.XPathParts; 43 44 public class GenerateProductionData { 45 static boolean DEBUG = false; 46 static boolean VERBOSE = false; 47 static Matcher FILE_MATCH = null; 48 49 static String SOURCE_COMMON_DIR = null; 50 static String DEST_COMMON_DIR = null; 51 52 static boolean ADD_LOGICAL_GROUPS = false; 53 static boolean ADD_DATETIME = false; 54 static boolean ADD_SIDEWAYS = false; 55 static boolean ADD_ROOT = false; 56 static boolean INCLUDE_COMPREHENSIVE = false; 57 static boolean CONSTRAINED_RESTORATION = false; 58 59 static final Set<String> NON_XML = ImmutableSet.of("dtd", "properties", "testData", "uca"); 60 static final Set<String> COPY_ANYWAY = ImmutableSet.of("casing", "collation"); // don't want to "clean up", makes format difficult to use 61 static final SupplementalDataInfo SDI = CLDRConfig.getInstance().getSupplementalDataInfo(); 62 63 static final Multimap<String, Pair<String, String>> localeToSubdivisionsToMigrate = TreeMultimap.create(); 64 65 enum MyOptions { 66 sourceDirectory(new Params() 67 .setHelp("source common directory") 68 .setDefault(CLDRPaths.COMMON_DIRECTORY) 69 .setMatch(".*")), 70 destinationDirectory(new Params() 71 .setHelp("destination common directory") 72 .setDefault(CLDRPaths.STAGING_DIRECTORY + "production/common") 73 .setMatch(".*")), 74 logicalGroups(new Params() 75 .setHelp("add path/values for logical groups") 76 .setDefault("true") 77 .setMatch("true|false")), 78 time(new Params() 79 .setHelp("add path/values for stock date/time/datetime") 80 .setDefault("true") 81 .setMatch("true|false")), 82 Sideways(new Params() 83 .setHelp("add path/values for sideways inheritance") 84 .setDefault("true") 85 .setMatch("true|false")), 86 root(new Params() 87 .setHelp("add path/values for root and code-fallback") 88 .setDefault("true") 89 .setMatch("true|false")), 90 constrainedRestoration(new Params() 91 .setHelp("only add inherited paths that were in original file") 92 .setDefault("true") 93 .setMatch("true|false")), 94 includeComprehensive(new Params() 95 .setHelp("exclude comprehensive paths — otherwise just to modern level") 96 .setDefault("true") 97 .setMatch("true|false")), 98 verbose(new Params() 99 .setHelp("verbose debugging messages")), 100 Debug(new Params() 101 .setHelp("debug")), 102 fileMatch(new Params() 103 .setHelp("regex to match patterns") 104 .setMatch(".*")), 105 ; 106 107 // BOILERPLATE TO COPY 108 final Option option; 109 MyOptions(Params params)110 private MyOptions(Params params) { 111 option = new Option(this, params); 112 } 113 114 private static Options myOptions = new Options(); 115 static { 116 for (MyOptions option : MyOptions.values()) { myOptions.add(option, option.option)117 myOptions.add(option, option.option); 118 } 119 } 120 parse(String[] args, boolean showArguments)121 private static Set<String> parse(String[] args, boolean showArguments) { 122 return myOptions.parse(MyOptions.values()[0], args, true); 123 } 124 } 125 main(String[] args)126 public static void main(String[] args) { 127 // TODO rbnf and segments don't have modern coverage; fix there. 128 129 MyOptions.parse(args, true); 130 SOURCE_COMMON_DIR = MyOptions.sourceDirectory.option.getValue(); 131 DEST_COMMON_DIR = MyOptions.destinationDirectory.option.getValue(); 132 133 // debugging 134 VERBOSE = MyOptions.verbose.option.doesOccur(); 135 DEBUG = MyOptions.Debug.option.doesOccur(); 136 String fileMatch = MyOptions.fileMatch.option.getValue(); 137 if (fileMatch != null) { 138 FILE_MATCH = Pattern.compile(fileMatch).matcher(""); 139 } 140 141 // controls for minimization 142 ADD_LOGICAL_GROUPS = "true".equalsIgnoreCase(MyOptions.logicalGroups.option.getValue()); 143 ADD_DATETIME = "true".equalsIgnoreCase(MyOptions.time.option.getValue()); 144 ADD_SIDEWAYS = "true".equalsIgnoreCase(MyOptions.Sideways.option.getValue()); 145 ADD_ROOT = "true".equalsIgnoreCase(MyOptions.root.option.getValue()); 146 147 // constraints 148 INCLUDE_COMPREHENSIVE = "true".equalsIgnoreCase(MyOptions.includeComprehensive.option.getValue()); 149 CONSTRAINED_RESTORATION = "true".equalsIgnoreCase(MyOptions.constrainedRestoration.option.getValue()); 150 151 // get directories 152 153 Arrays.asList(DtdType.values()).parallelStream() 154 .unordered() 155 .forEach(type -> { 156 boolean isLdmlDtdType = type == DtdType.ldml; 157 158 // bit of a hack, using the ldmlICU — otherwise unused! — to get the nonXML files. 159 Set<String> directories = (type == DtdType.ldmlICU) ? NON_XML : type.directories; 160 161 for (String dir : directories) { 162 File sourceDir = new File(SOURCE_COMMON_DIR, dir); 163 File destinationDir = new File(DEST_COMMON_DIR, dir); 164 Stats stats = new Stats(); 165 copyFilesAndReturnIsEmpty(sourceDir, destinationDir, null, isLdmlDtdType, stats); 166 } 167 }); 168 if (!localeToSubdivisionsToMigrate.isEmpty()) { 169 System.err.println("WARNING: Subdivision files not written"); 170 for (Entry<String, Pair<String, String>> entry : localeToSubdivisionsToMigrate.entries()) { 171 System.err.println(entry.getKey() + " \t" + entry.getValue()); 172 } 173 } 174 } 175 176 private static class Stats { 177 long files; 178 long removed; 179 long retained; 180 long remaining; clear()181 Stats clear() { 182 files = removed = retained = remaining = 0; 183 return this; 184 } 185 @Override toString()186 public String toString() { 187 return 188 "files=" + files 189 + (removed + retained + remaining == 0 ? "" 190 : "; removed=" + removed 191 + "; retained=" + retained 192 + "; remaining=" + remaining); 193 } showNonZero(String label)194 public void showNonZero(String label) { 195 if (removed + retained + remaining != 0) { 196 System.out.println(label + toString()); 197 } 198 } 199 } 200 201 /** 202 * Copy files in directories, recursively. 203 * @param sourceFile 204 * @param destinationFile 205 * @param factory 206 * @param isLdmlDtdType 207 * @param stats 208 * @param hasChildren 209 * @return true if the file is an ldml file with empty content. 210 */ copyFilesAndReturnIsEmpty(File sourceFile, File destinationFile, Factory factory, boolean isLdmlDtdType, final Stats stats)211 private static boolean copyFilesAndReturnIsEmpty(File sourceFile, File destinationFile, 212 Factory factory, boolean isLdmlDtdType, final Stats stats) { 213 if (sourceFile.isDirectory()) { 214 215 System.out.println(sourceFile + " => " + destinationFile); 216 if (!destinationFile.mkdirs()) { 217 // if created, remove old contents 218 Arrays.stream(destinationFile.listFiles()).forEach(File::delete); 219 } 220 221 Set<String> sorted = new TreeSet<>(); 222 sorted.addAll(Arrays.asList(sourceFile.list())); 223 224 if (COPY_ANYWAY.contains(sourceFile.getName())) { // special cases 225 isLdmlDtdType = false; 226 } 227 // reset factory for directory 228 factory = null; 229 if (isLdmlDtdType) { 230 // if the factory is empty, then we just copy files 231 factory = Factory.make(sourceFile.toString(), ".*"); 232 } 233 boolean isMainDir = factory != null && sourceFile.getName().contentEquals("main"); 234 boolean isRbnfDir = factory != null && sourceFile.getName().contentEquals("rbnf"); 235 236 Set<String> emptyLocales = new HashSet<>(); 237 final Stats stats2 = new Stats(); 238 final Factory theFactory = factory; 239 final boolean isLdmlDtdType2 = isLdmlDtdType; 240 sorted 241 .parallelStream() 242 .forEach(file -> { 243 File sourceFile2 = new File(sourceFile, file); 244 File destinationFile2 = new File(destinationFile, file); 245 if (VERBOSE) System.out.println("\t" + file); 246 247 // special step to just copy certain files like main/root.xml file 248 Factory currFactory = theFactory; 249 if (isMainDir) { 250 if (file.equals("root.xml")) { 251 currFactory = null; 252 } 253 } else if (isRbnfDir) { 254 currFactory = null; 255 } 256 257 // when the currFactory is null, we just copy files as-is 258 boolean isEmpty = copyFilesAndReturnIsEmpty(sourceFile2, destinationFile2, currFactory, isLdmlDtdType2, stats2); 259 if (isEmpty) { // only happens for ldml 260 emptyLocales.add(file.substring(0,file.length()-4)); // remove .xml for localeId 261 } 262 }); 263 stats2.showNonZero("\tTOTAL:\t"); 264 // if there are empty ldml files, AND we aren't in /main/, 265 // then remove any without children 266 if (!emptyLocales.isEmpty() && !sourceFile.getName().equals("main")) { 267 Set<String> childless = getChildless(emptyLocales, factory.getAvailable()); 268 if (!childless.isEmpty()) { 269 if (VERBOSE) System.out.println("\t" + destinationFile + "\tRemoving empty locales:" + childless); 270 childless.stream().forEach(locale -> new File(destinationFile, locale + ".xml").delete()); 271 } 272 } 273 return false; 274 } else if (factory != null) { 275 String file = sourceFile.getName(); 276 if (!file.endsWith(".xml")) { 277 return false; 278 } 279 String localeId = file.substring(0, file.length()-4); 280 if (FILE_MATCH != null) { 281 if (!FILE_MATCH.reset(localeId).matches()) { 282 return false; 283 } 284 } 285 boolean isRoot = localeId.equals("root"); 286 String directoryName = sourceFile.getParentFile().getName(); 287 boolean isSubdivisionDirectory = "subdivisions".equals(directoryName); 288 289 CLDRFile cldrFileUnresolved = factory.make(localeId, false); 290 CLDRFile cldrFileResolved = factory.make(localeId, true); 291 boolean gotOne = false; 292 Set<String> toRemove = new TreeSet<>(); // TreeSet just makes debugging easier 293 Set<String> toRetain = new TreeSet<>(); 294 Output<String> pathWhereFound = new Output<>(); 295 Output<String> localeWhereFound = new Output<>(); 296 297 boolean isArabicSpecial = localeId.equals("ar") || localeId.startsWith("ar_"); 298 299 String debugPath = null; // "//ldml/units/unitLength[@type=\"short\"]/unit[@type=\"power-kilowatt\"]/displayName"; 300 String debugLocale = "af"; 301 302 for (String xpath : cldrFileUnresolved) { 303 if (xpath.startsWith("//ldml/identity")) { 304 continue; 305 } 306 if (debugPath != null && localeId.equals(debugLocale) && xpath.equals(debugPath)) { 307 int debug = 0; 308 } 309 310 String value = cldrFileUnresolved.getStringValue(xpath); 311 if (value == null || CldrUtility.INHERITANCE_MARKER.equals(value)) { 312 toRemove.add(xpath); 313 continue; 314 } 315 316 // special-case the root values that are only for Survey Tool use 317 318 if (isRoot) { 319 if (xpath.startsWith("//ldml/annotations/annotation")) { 320 toRemove.add(xpath); 321 continue; 322 } 323 } 324 325 // special case for Arabic defaultNumberingSystem 326 if (isArabicSpecial && xpath.contains("/defaultNumberingSystem")) { 327 toRetain.add(xpath); 328 } 329 330 // remove items that are the same as their bailey values. This also catches Inheritance Marker 331 332 String bailey = cldrFileResolved.getConstructedBaileyValue(xpath, pathWhereFound, localeWhereFound); 333 if (value.equals(bailey) 334 && (!ADD_SIDEWAYS 335 || pathEqualsOrIsAltVariantOf(xpath, pathWhereFound.value)) 336 && (!ADD_ROOT 337 || (!Objects.equals(XMLSource.ROOT_ID, localeWhereFound.value) 338 && !Objects.equals(XMLSource.CODE_FALLBACK_ID, localeWhereFound.value)))) { 339 toRemove.add(xpath); 340 continue; 341 } 342 343 // Move subdivisions elsewhere 344 if (!isSubdivisionDirectory && xpath.startsWith("//ldml/localeDisplayNames/subdivisions/subdivision")) { 345 localeToSubdivisionsToMigrate.put(localeId, Pair.of(xpath, value)); 346 toRemove.add(xpath); 347 continue; 348 } 349 // remove level=comprehensive (under setting) 350 351 if (!INCLUDE_COMPREHENSIVE) { 352 Level coverage = SDI.getCoverageLevel(xpath, localeId); 353 if (coverage == Level.COMPREHENSIVE) { 354 toRemove.add(xpath); 355 continue; 356 } 357 } 358 359 // if we got all the way to here, we have a non-empty result 360 361 // check to see if we might need to flesh out logical groups 362 // TODO Should be done in the converter tool!! 363 if (ADD_LOGICAL_GROUPS && !LogicalGrouping.isOptional(cldrFileResolved, xpath)) { 364 Set<String> paths = LogicalGrouping.getPaths(cldrFileResolved, xpath); 365 if (paths != null && paths.size() > 1) { 366 for (String possiblePath : paths) { 367 // Unclear from API whether we need to do this filtering 368 if (!LogicalGrouping.isOptional(cldrFileResolved, possiblePath)) { 369 toRetain.add(possiblePath); 370 } 371 } 372 } 373 } 374 375 // check to see if we might need to flesh out datetime. 376 // TODO Should be done in the converter tool!! 377 if (ADD_DATETIME && isDateTimePath(xpath)) { 378 toRetain.addAll(dateTimePaths(xpath)); 379 } 380 381 // past the gauntlet 382 gotOne = true; 383 } 384 385 // we even add empty files, but can delete them back on the directory level. 386 try (PrintWriter pw = new PrintWriter(destinationFile)) { 387 CLDRFile outCldrFile = cldrFileUnresolved.cloneAsThawed(); 388 if (isSubdivisionDirectory) { 389 Collection<Pair<String, String>> path_values = localeToSubdivisionsToMigrate.get(localeId); 390 if (path_values != null) { 391 for (Pair<String, String>path_value : path_values) { 392 outCldrFile.add(path_value.getFirst(), path_value.getSecond()); 393 } 394 localeToSubdivisionsToMigrate.removeAll(localeId); 395 } 396 } 397 398 // Remove paths, but pull out the ones to retain 399 // example: 400 // toRemove == {a b c} // c may have ^^^ value 401 // toRetain == {b c d} // d may have ^^^ value 402 403 if (DEBUG) { 404 showIfNonZero(localeId, "removing", toRemove); 405 showIfNonZero(localeId, "retaining", toRetain); 406 407 } 408 if (CONSTRAINED_RESTORATION) { 409 toRetain.retainAll(toRemove); // only add paths that were there already 410 // toRetain == {b c} 411 if (DEBUG) { 412 showIfNonZero(localeId, "constrained retaining", toRetain); 413 } 414 } 415 416 boolean changed0 = toRemove.removeAll(toRetain); 417 // toRemove == {a} 418 if (DEBUG && changed0) { 419 showIfNonZero(localeId, "final removing", toRemove); 420 } 421 422 boolean changed = toRetain.removeAll(toRemove); 423 // toRetain = {b c d} or if constrained, {b c} 424 if (DEBUG && changed) { 425 showIfNonZero(localeId, "final retaining", toRetain); 426 } 427 428 outCldrFile.removeAll(toRemove, false); 429 if (DEBUG) { 430 for (String xpath : toRemove) { 431 System.out.println(localeId + ": removing: «" 432 + cldrFileUnresolved.getStringValue(xpath) 433 + "», " + xpath); 434 } 435 } 436 437 // now set any null values to bailey values if not present 438 for (String xpath : toRetain) { 439 if (debugPath != null && localeId.equals(debugLocale) && xpath.equals(debugPath)) { 440 int debug = 0; 441 } 442 String value = cldrFileResolved.getStringValue(xpath); 443 if (value == null || value.equals(CldrUtility.INHERITANCE_MARKER)) { 444 throw new IllegalArgumentException(localeId + ": " + value + " in value for " + xpath); 445 } else { 446 if (DEBUG) { 447 String oldValue = cldrFileUnresolved.getStringValue(xpath); 448 System.out.println("Restoring: «" + oldValue + "» ⇒ «" + value 449 + "»\t" + xpath); 450 } 451 outCldrFile.add(xpath, value); 452 } 453 } 454 455 // double-check results 456 int count = 0; 457 for (String xpath : outCldrFile) { 458 if (debugPath != null && localeId.equals(debugLocale) && xpath.equals(debugPath)) { 459 int debug = 0; 460 } 461 String value = outCldrFile.getStringValue(xpath); 462 if (value == null || value.equals(CldrUtility.INHERITANCE_MARKER)) { 463 throw new IllegalArgumentException(localeId + ": " + value + " in value for " + xpath); 464 } 465 } 466 467 outCldrFile.write(pw); 468 ++stats.files; 469 stats.removed += toRemove.size(); 470 stats.retained += toRetain.size(); 471 stats.remaining += count; 472 } catch (FileNotFoundException e) { 473 throw new UncheckedIOException("Can't copy " + sourceFile + " to " + destinationFile + " — ", e); 474 } 475 return !gotOne; 476 } else { 477 if (FILE_MATCH != null) { 478 String file = sourceFile.getName(); 479 int dotPos = file.lastIndexOf('.'); 480 String baseName = dotPos >= 0 ? file.substring(0, file.length()-dotPos) : file; 481 if (!FILE_MATCH.reset(baseName).matches()) { 482 return false; 483 } 484 } 485 // for now, just copy 486 ++stats.files; 487 copyFiles(sourceFile, destinationFile); 488 return false; 489 } 490 } 491 showIfNonZero(String localeId, String title, Set<String> toRemove)492 private static void showIfNonZero(String localeId, String title, Set<String> toRemove) { 493 if (toRemove.size() != 0) { 494 System.out.println(localeId + ": " 495 + title 496 + ": " + toRemove.size()); 497 } 498 } 499 pathEqualsOrIsAltVariantOf(String desiredPath, String foundPath)500 private static boolean pathEqualsOrIsAltVariantOf(String desiredPath, String foundPath) { 501 if (desiredPath.equals(foundPath)) { 502 return true; 503 } 504 if (desiredPath.contains("type=\"en_GB\"") && desiredPath.contains("alt=")) { 505 int debug = 0; 506 } 507 if (foundPath == null) { 508 // We can do this, because the bailey value has already been checked 509 // Since it isn't null, a null indicates a constructed alt value 510 return true; 511 } 512 XPathParts desiredPathParts = XPathParts.getFrozenInstance(desiredPath); 513 XPathParts foundPathParts = XPathParts.getFrozenInstance(foundPath); 514 if (desiredPathParts.size() != foundPathParts.size()) { 515 return false; 516 } 517 for (int e = 0; e < desiredPathParts.size(); ++e) { 518 String element1 = desiredPathParts.getElement(e); 519 String element2 = foundPathParts.getElement(e); 520 if (!element1.equals(element2)) { 521 return false; 522 } 523 Map<String, String> attr1 = desiredPathParts.getAttributes(e); 524 Map<String, String> attr2 = foundPathParts.getAttributes(e); 525 if (attr1.equals(attr2)) { 526 continue; 527 } 528 Set<String> keys1 = attr1.keySet(); 529 Set<String> keys2 = attr2.keySet(); 530 for (String attr : Sets.union(keys1, keys2)) { 531 if (attr.equals("alt")) { 532 continue; 533 } 534 if (!Objects.equals(attr1.get(attr), attr2.get(attr))) { 535 return false; 536 } 537 } 538 } 539 return true; 540 } 541 isDateTimePath(String xpath)542 private static boolean isDateTimePath(String xpath) { 543 return xpath.startsWith("//ldml/dates/calendars/calendar") 544 && xpath.contains("FormatLength[@type="); 545 } 546 547 /** generate full dateTimePaths from any element 548 //ldml/dates/calendars/calendar[@type="gregorian"]/dateFormats/dateFormatLength[@type=".*"]/dateFormat[@type="standard"]/pattern[@type="standard"] 549 //ldml/dates/calendars/calendar[@type="gregorian"]/timeFormats/timeFormatLength[@type=".*"]/timeFormat[@type="standard"]/pattern[@type="standard"] 550 //ldml/dates/calendars/calendar[@type="gregorian"]/dateTimeFormats/dateTimeFormatLength[@type=".*"]/dateTimeFormat[@type="standard"]/pattern[@type="standard"] 551 */ dateTimePaths(String xpath)552 private static Set<String> dateTimePaths(String xpath) { 553 LinkedHashSet<String> result = new LinkedHashSet<>(); 554 String prefix = xpath.substring(0,xpath.indexOf(']') + 2); // get after ]/ 555 for (String type : Arrays.asList("date", "time", "dateTime")) { 556 String pattern = prefix + "$XFormats/$XFormatLength[@type=\"$Y\"]/$XFormat[@type=\"standard\"]/pattern[@type=\"standard\"]".replace("$X", type); 557 for (String width : Arrays.asList("full", "long", "medium", "short")) { 558 result.add(pattern.replace("$Y", width)); 559 } 560 } 561 return result; 562 } 563 getChildless(Set<String> emptyLocales, Set<String> available)564 private static Set<String> getChildless(Set<String> emptyLocales, Set<String> available) { 565 // first build the parent2child map 566 Multimap<String,String> parent2child = HashMultimap.create(); 567 for (String locale : available) { 568 String parent = LocaleIDParser.getParent(locale); 569 if (parent != null) { 570 parent2child.put(parent, locale); 571 } 572 } 573 574 // now cycle through the empties 575 Set<String> result = new HashSet<>(); 576 for (String empty : emptyLocales) { 577 if (allChildrenAreEmpty(empty, emptyLocales, parent2child)) { 578 result.add(empty); 579 } 580 } 581 return result; 582 } 583 584 /** 585 * Recursively checks that all children are empty (including that there are no children) 586 * @param name 587 * @param emptyLocales 588 * @param parent2child 589 * @return 590 */ allChildrenAreEmpty( String locale, Set<String> emptyLocales, Multimap<String, String> parent2child)591 private static boolean allChildrenAreEmpty( 592 String locale, 593 Set<String> emptyLocales, 594 Multimap<String, String> parent2child) { 595 596 Collection<String> children = parent2child.get(locale); 597 for (String child : children) { 598 if (!emptyLocales.contains(child)) { 599 return false; 600 } 601 if (!allChildrenAreEmpty(child, emptyLocales, parent2child)) { 602 return false; 603 } 604 } 605 return true; 606 } 607 copyFiles(File sourceFile, File destinationFile)608 private static void copyFiles(File sourceFile, File destinationFile) { 609 try { 610 Files.copy(sourceFile, destinationFile); 611 } catch (IOException e) { 612 System.err.println("Can't copy " + sourceFile + " to " + destinationFile + " — " + e); 613 } 614 } 615 } 616