PackageGappTask.java
001 /*
002  *  Copyright (c) 1995-2012, The University of Sheffield. See the file
003  *  COPYRIGHT.txt in the software or at http://gate.ac.uk/gate/COPYRIGHT.txt
004  *
005  *  This file is part of GATE (see http://gate.ac.uk/), and is free
006  *  software, licenced under the GNU Library General Public License,
007  *  Version 2, June 1991 (in the distribution as file licence.html,
008  *  and also available at http://gate.ac.uk/gate/licence.html).
009  *
010  *  Ian Roberts, 10/02/2009
011  *
012  *  $Id: PackageGappTask.java 17705 2014-03-19 17:49:33Z ian_roberts $
013  */
014 package gate.util.ant.packager;
015 
016 import gate.util.Files;
017 import gate.util.ant.ExpandIvy;
018 import gate.util.persistence.PersistenceManager;
019 
020 import java.io.File;
021 import java.io.IOException;
022 import java.net.MalformedURLException;
023 import java.net.URL;
024 import java.util.ArrayList;
025 import java.util.Comparator;
026 import java.util.HashMap;
027 import java.util.HashSet;
028 import java.util.Iterator;
029 import java.util.LinkedHashMap;
030 import java.util.List;
031 import java.util.Map;
032 import java.util.Set;
033 import java.util.SortedMap;
034 import java.util.TreeMap;
035 import java.util.TreeSet;
036 
037 import org.apache.tools.ant.BuildException;
038 import org.apache.tools.ant.Project;
039 import org.apache.tools.ant.Task;
040 import org.apache.tools.ant.taskdefs.Copy;
041 import org.apache.tools.ant.taskdefs.Property;
042 import org.apache.tools.ant.types.FileSet;
043 import org.apache.tools.ant.types.Path;
044 import org.apache.tools.ant.types.PatternSet.NameEntry;
045 import org.apache.tools.ant.util.FileUtils;
046 import org.jdom.Document;
047 import org.jdom.Element;
048 import org.jdom.JDOMException;
049 import org.jdom.input.SAXBuilder;
050 import org.jdom.xpath.XPath;
051 
052 /**
053  * Ant task to copy a gapp file, rewriting any relative paths it
054  * contains to point within the same directory as the target file
055  * location and copy the referenced files into the right locations. The
056  * resulting structure is self-contained and can be packaged up (e.g. in
057  * a zip file) to send to a third party.
058  
059  @author Ian Roberts
060  */
061 public class PackageGappTask extends Task {
062   
063   /**
064    * Comparator to compare URLs by lexicographic ordering of their
065    * getPath() values.  <code>null</code> compares less-than anything
066    * not <code>null</code>.
067    */
068   public static final Comparator<URL> PATH_COMPARATOR = new Comparator<URL>() {
069     @Override
070     public int compare(URL a, URL b) {
071       if(a == null) {
072         return (b == null: -1;
073       }
074       if(b == null) {
075         return 1;
076       }
077       return a.getPath().compareTo(b.getPath());
078     }
079   };
080 
081   /**
082    * The file into which the modified gapp will be written.
083    */
084   private File destFile;
085 
086   /**
087    * The original file containing the gapp to package.
088    */
089   private File src;
090 
091   /**
092    * The location of the GATE home directory.  Only required if the GAPP file
093    * to be packaged contains URLs relative to $gatehome$.
094    */
095   private File gateHome;
096 
097   /**
098    * The location of the resources home directory.  Only required if the GAPP file
099    * to be packaged contains URLs relative to $resourceshome$.
100    */
101   private File resourcesHome;
102 
103   /**
104    * Should we copy the complete contents of referenced plugin
105    * directories into the right place relative to the destFile? If not,
106    * only the creole.xmls, any JARs they directly include, and directly
107    * referenced resource files will be copied. Anything else needs to be
108    * declared in an &lt;extrafiles&gt; sub-element.
109    */
110   private boolean copyPlugins = true;
111   
112   /**
113    * Should we expand any Ivy based dependencies to create a standalone
114    * application. If true then local copies of each dependency will be
115    */
116   private boolean expandIvy = false;
117 
118   /**
119    * Should we copy the complete contents of the parent directories of
120    * any referenced resource files? If true, whenever the gapp
121    * references a resource file <code>f</code> we will also include
122    * the whole contents of <code>f.getParentFile()</code>.
123    */
124   private boolean copyResourceDirs = false;
125   
126   /**
127    * Path-like structure listing extra resources that should be packaged
128    * with the gapp, as if they had been referenced by relpaths from
129    * within the gapp file. Their target locations are determined by the
130    * plugins and mapping hints in the usual way. Typically this would be
131    * used for other resource files that are not referenced directly by
132    * the gapp file but are referenced indirectly by the PRs in the
133    * application (e.g. the .lst files corresponding to a gazetteer
134    * .def).
135    */
136   private List<Path> extraResourcesPaths = new ArrayList<Path>();
137 
138   /**
139    * Enumeration of the actions to take when there are unresolved
140    * resources. Options are to fail the build, to make the paths
141    * absolute in the new gapp, or to recover by gathering the unresolved
142    * files into an "application-resources" directory.
143    */
144   public static enum UnresolvedAction {
145     fail, absolute, recover
146   }
147 
148   /**
149    * The action to take when there are unresolved resources. By default,
150    * unresolved resources will fail the build.
151    */
152   private UnresolvedAction onUnresolved = UnresolvedAction.fail;
153 
154   /**
155    * List of mapping hint sub-elements.
156    */
157   private List<MappingHint> hintTasks = new ArrayList<MappingHint>();
158 
159   /**
160    * Map of mapping hints.  This is an insertion-ordered LinkedHashMap, so
161    * where two hints could apply to the same path, the one specified first in
162    * the configuration wins.
163    */
164   private Map<URL, String> mappingHints = new LinkedHashMap<URL, String>();
165 
166   /**
167    * Get the destination file to which the modified gapp will be
168    * written.
169    */
170   public File getDestFile() {
171     return destFile;
172   }
173 
174   /**
175    * Set the destination file to which the modified gapp will be
176    * written.
177    */
178   public void setDestFile(File destFile) {
179     this.destFile = destFile;
180   }
181 
182   /**
183    * Get the original gapp file that is to be modified.
184    */
185   public File getSrc() {
186     return src;
187   }
188 
189   /**
190    * Set the location of the original gapp file which is to be modified.
191    */
192   public void setSrc(File src) {
193     this.src = src;
194   }
195 
196   /**
197    * Get the location of the GATE home directory, used to resolve $gatehome$
198    * relative paths in the GAPP file.
199    */
200   public File getGateHome() {
201     return gateHome;
202   }
203 
204   /**
205    * Set the location of the GATE home directory, used to resolve $gatehome$
206    * relative paths in the GAPP file.
207    */
208   public void setGateHome(File gateHome) {
209     this.gateHome = gateHome;
210   }
211 
212   /**
213    * Get the location of the resources home directory, used to resolve $resourceshome$
214    * relative paths in the GAPP file.
215    */
216   public File getResourcesHome() {
217     return resourcesHome;
218   }
219 
220   /**
221    * Set the location of the resources home directory, used to resolve $resourceshome$
222    * relative paths in the GAPP file.
223    */
224   public void setResourcesHome(File resourcesHome) {
225     this.resourcesHome = resourcesHome;
226   }
227 
228   /**
229    * Will the task copy the complete contents of referenced plugins into
230    * the target location?
231    */
232   public boolean isCopyPlugins() {
233     return copyPlugins;
234   }
235 
236   /**
237    * Will the task copy the complete contents of referenced plugins into
238    * the target location? If false, only the bare minimum will be copied
239    * (the creole.xml files, any JARs referenced therein, and any
240    * directly referenced resource files). Anything extra must be copied
241    * in separately, typically with extra &lt;copy&gt; tasks after the
242    * &lt;packagegapp&gt; one.
243    */
244   public void setCopyPlugins(boolean copyPlugins) {
245     this.copyPlugins = copyPlugins;
246   }
247   
248   public void setExpandIvy(boolean expandIvy) {
249     this.expandIvy = expandIvy;
250   }
251   
252   public boolean getExpandIvy() {
253     return expandIvy;
254   }
255 
256   /**
257    * Will the task copy the complete contents of directories containing
258    * referenced resources into the target location or just the
259    * referenced resources themselves?
260    */
261   public boolean isCopyResourceDirs() {
262     return copyResourceDirs;
263   }
264 
265   /**
266    * Will the task copy the complete contents of directories containing
267    * referenced resources into the target location? By default it does
268    * not do this, but only includes the directly-referenced resource
269    * files - for example, if the gapp refers to a <code>.def</code>
270    * file defining gazetteer lists, the lists themselves will not be
271    * included. If copyResourceDirs is false, the additional resources
272    * will need to be included using an appropriate
273    * &lt;extraresourcespath&gt;.
274    */
275   public void setCopyResourceDirs(boolean copyResourceDirs) {
276     this.copyResourceDirs = copyResourceDirs;
277   }
278 
279   /**
280    * Get the action performed when there are unresolved resources.
281    */
282   public UnresolvedAction getOnUnresolved() {
283     return onUnresolved;
284   }
285 
286   /**
287    * What should we do if there are unresolved relpaths within the gapp
288    * file? By default the build will fail, but instead you can opt to
289    * have the relative paths replaced by absolute paths to the same URL,
290    * or to have the task recover by putting the files into an
291    * "application-resources" directory.
292    */
293   public void setOnUnresolved(UnresolvedAction onUnresolved) {
294     this.onUnresolved = onUnresolved;
295   }
296 
297   /**
298    * Create and add the representation for a nested &lt;hint from="X"
299    * to="Y" /&gt; element.
300    */
301   public MappingHint createHint() {
302     MappingHint hint = new MappingHint();
303     hint.setProject(this.getProject());
304     hint.setTaskName(this.getTaskName());
305     hint.setLocation(this.getLocation());
306     hint.init();
307     hintTasks.add(hint);
308     return hint;
309   }
310 
311   /**
312    * Add a path containing extra resources that should be treated as if
313    * they had been referenced by relpaths within the gapp file. The
314    * locations to which these extra resources will be copied are
315    * determined by the plugins and mapping hints in the usual way.
316    */
317   public void addExtraResourcesPath(Path path) {
318     extraResourcesPaths.add(path);
319   }
320 
321   @Override
322   public void execute() throws BuildException {
323     // process the hints
324     for(MappingHint h : hintTasks) {
325       h.perform();
326     }
327 
328     // map to store the necessary file copy operations
329     Map<URL, URL> fileCopyMap = new HashMap<URL, URL>();
330     Map<URL, URL> dirCopyMap = new HashMap<URL, URL>();
331     TreeMap<URL, URL> pluginCopyMap = new TreeMap<URL, URL>(PATH_COMPARATOR);
332 
333     log("Packaging gapp file " + src);
334     // do the work
335     GappModel gappModel = null;
336     URL newFileURL = null;
337     try {
338       URL gateHomeURL = null;
339       // convert gateHome to a URL, if it was provided
340       if(gateHome != null) {
341         gateHomeURL = gateHome.toURI().toURL();
342       }
343       URL resourcesHomeURL = null;
344       // convert resourcesHome to a URL, if it was provided
345       if(resourcesHome != null) {
346         resourcesHomeURL = resourcesHome.toURI().toURL();
347       }
348       gappModel = new GappModel(src.toURI().toURL(), gateHomeURL, resourcesHomeURL);
349       newFileURL = destFile.toURI().toURL();
350       gappModel.setGappFileURL(newFileURL);
351     }
352     catch(MalformedURLException e) {
353       throw new BuildException("Couldn't convert src or dest file to URL", e,
354               getLocation());
355     }
356 
357     // we use TreeSet for these sets so we will process the paths
358     // higher up the directory tree before paths pointing to their
359     // subdirectories.
360     Set<URL> plugins = new TreeSet<URL>(PATH_COMPARATOR);
361     plugins.addAll(gappModel.getPluginURLs());
362     Set<URL> resources = new TreeSet<URL>(PATH_COMPARATOR);
363     resources.addAll(gappModel.getResourceURLs());
364 
365     // process the extraresourcespath elements (if any)
366     processExtraResourcesPaths(resources);
367 
368     // first look at the explicit mapping hints
369     if(mappingHints != null && !mappingHints.isEmpty()) {
370       Iterator<URL> resourcesIt = resources.iterator();
371       while(resourcesIt.hasNext()) {
372         URL resource = resourcesIt.next();
373         for(URL hint : mappingHints.keySet()) {
374           String hintString = hint.toExternalForm();
375           if(resource.equals(hint)
376                   || (hintString.endsWith("/"&& resource.toExternalForm()
377                           .startsWith(hintString))) {
378             // found this resource under this hint
379             log("Found resource " + resource + " under mapping hint URL "
380                     + hint, Project.MSG_VERBOSE);
381             String hintTarget = mappingHints.get(hint);
382             URL newResourceURL = null;
383             if(hintTarget == null) {
384               // hint asks to map to an absolute URL
385               log("  Converting to absolute URL", Project.MSG_VERBOSE);
386               newResourceURL = resource;
387             }
388             else {
389               // relativize the URL against the hint source and
390               // construct the new URL relative to the hint target
391               try {
392                 URL mappedHint = new URL(newFileURL, hintTarget);
393                 String resourceRelpath =
394                         PersistenceManager.getRelativePath(hint, resource);
395                 newResourceURL = new URL(mappedHint, resourceRelpath);
396                 fileCopyMap.put(resource, newResourceURL);
397                 if(copyResourceDirs) {
398                   dirCopyMap.put(new URL(resource, ".")new URL(
399                           newResourceURL, "."));
400                 }
401               }
402               catch(MalformedURLException e) {
403                 throw new BuildException("Couldn't construct URL relative to "
404                         + hintTarget + " for " + resource, e, getLocation());
405               }
406               log("  Relocating to " + newResourceURL, Project.MSG_VERBOSE);
407             }
408             // do the relocation
409             gappModel.updatePathForURL(resource, newResourceURL,
410                     hintTarget != null);
411             // we've now dealt with this resource, so don't need to
412             // handle it later
413             resourcesIt.remove();
414             break;
415           }
416         }
417       }
418     }
419 
420     // Any resources that aren't covered by the hints, try and
421     // resolve them relative to the plugins referenced by the
422     // application.
423     Iterator<URL> pluginsIt = plugins.iterator();
424     while(pluginsIt.hasNext()) {
425       URL pluginURL = pluginsIt.next();
426       pluginsIt.remove();
427       URL newPluginURL = null;
428       
429       String pluginName = pluginURL.getFile();
430       log("Processing plugin " + pluginName, Project.MSG_VERBOSE);
431 
432       // first check whether this plugin is a subdirectory of another plugin
433       // we have already processed
434       SortedMap<URL, URL> possibleAncestors = pluginCopyMap.headMap(pluginURL);
435       URL ancestorPlugin = null;
436       if(!possibleAncestors.isEmpty()) ancestorPlugin = possibleAncestors.lastKey();
437       if(ancestorPlugin != null && pluginURL.toExternalForm().startsWith(
438               ancestorPlugin.toExternalForm())) {
439         // this plugin is under one we have already dealt with
440         log("  Plugin is located under another plugin " + ancestorPlugin, Project.MSG_VERBOSE);
441         String relPath = PersistenceManager.getRelativePath(ancestorPlugin, pluginURL);
442         try {
443           newPluginURL = new URL(pluginCopyMap.get(ancestorPlugin), relPath);
444         }
445         catch(MalformedURLException e) {
446           throw new BuildException("Couldn't construct URL relative to plugins/"
447                   " for " + pluginURL, e, getLocation());
448         }
449       }
450       else {
451         // normal case, this plugin is not a subdir of another plugin
452         boolean addSlash = false;
453         // we will map the plugin whose directory name is X to plugins/X
454         if(pluginName.endsWith("/")) {
455           addSlash = true;
456           pluginName = pluginName.substring(
457                   pluginName.lastIndexOf('/', pluginName.length() 21,
458                   pluginName.length() 1);
459         }
460         else {
461           pluginName = pluginName.substring(pluginName.lastIndexOf('/'1);
462         }
463         log("  Plugin name is " + pluginName, Project.MSG_VERBOSE);
464         try {
465           newPluginURL = new URL(newFileURL, "plugins/" + pluginName + (addSlash ? "/" ""));
466           // a gapp may refer to two or more plugins with the same name.
467           // If plugins/{pluginName} is already taken, try
468           // plugins/{pluginName}-2, plugins/{pluginName}-3, etc.,
469           // until we find one that is available.
470           if(pluginCopyMap.containsValue(newPluginURL)) {
471             int index = 2;
472             do {
473               newPluginURL =
474                       new URL(newFileURL, "plugins/" + pluginName + "-"
475                               (index++(addSlash ? "/" ""));
476             while(pluginCopyMap.containsValue(newPluginURL));
477           }
478         }
479         catch(MalformedURLException e) {
480           throw new BuildException("Couldn't construct URL relative to plugins/"
481                   " for " + pluginURL, e, getLocation());
482         }
483       }
484       log("  Relocating to " + newPluginURL, Project.MSG_VERBOSE);
485 
486       // deal with the plugin URL itself (in the urlList)
487       gappModel.updatePathForURL(pluginURL, newPluginURL, true);
488       pluginCopyMap.put(pluginURL, newPluginURL);
489 
490       // now look for resources located under that plugin
491       String pluginUri = pluginURL.toExternalForm();
492       if(!pluginUri.endsWith("/")) {
493         pluginUri += "/";
494       }
495       Iterator<URL> resourcesIt = resources.iterator();
496       while(resourcesIt.hasNext()) {
497         URL resourceURL = resourcesIt.next();
498         try {
499           if(resourceURL.toExternalForm().startsWith(
500                   pluginUri)) {
501             // found a resource under this plugin, so relocate it to be
502             // under the re-located plugin dir
503             resourcesIt.remove();
504             String resourceRelpath =
505                     PersistenceManager.getRelativePath(pluginURL, resourceURL);
506             log("    Found resource " + resourceURL, Project.MSG_VERBOSE);
507             URL newResourceURL = null;
508             newResourceURL = new URL(newPluginURL, resourceRelpath);
509             log("    Relocating to " + newResourceURL, Project.MSG_VERBOSE);
510             gappModel.updatePathForURL(resourceURL, newResourceURL, true);
511             fileCopyMap.put(resourceURL, newResourceURL);
512             if(copyResourceDirs) {
513               dirCopyMap.put(new URL(resourceURL, ".")new URL(newResourceURL,
514                       "."));
515             }
516           }
517         }
518         catch(MalformedURLException e) {
519           throw new BuildException("Couldn't construct URL relative to "
520                   + newPluginURL + " for " + resourceURL, e, getLocation());
521         }
522       }
523     }
524 
525     // anything left over, handle according to onUnresolved
526     if(!resources.isEmpty()) {
527       switch(onUnresolved) {
528         case fail:
529           // easy case - fail the build
530           log("There were unresolved resources:", Project.MSG_ERR);
531           for(URL res : resources) {
532             log(res.toExternalForm(), Project.MSG_ERR);
533           }
534           log("Either set onUnresolved=\"absolute|recover\" or add the "
535                   "relevant mapping hints", Project.MSG_ERR);
536           throw new BuildException("There were unresolved resources",
537                   getLocation());
538 
539         case absolute:
540           // convert all unresolved resources to absolute URLs
541           log("There were unresolved resources, which have been made absolute",
542                   Project.MSG_WARN);
543           for(URL res : resources) {
544             gappModel.updatePathForURL(res, res, false);
545             log(res.toExternalForm(), Project.MSG_VERBOSE);
546           }
547           break;
548 
549         case recover:
550           // the clever case - recover by putting all the unresolved
551           // resources into subdirectories of an "application-resources"
552           // directory under the output dir
553           URL unresolvedResourcesDir = null;
554           try {
555             unresolvedResourcesDir =
556                     new URL(newFileURL, "application-resources/");
557           }
558           catch(MalformedURLException e) {
559             throw new BuildException("Can't construct URL relative to "
560                     + newFileURL + " for application-resources", e,
561                     getLocation());
562           }
563           // map to track where under application-resources we should map
564           // each directory that contains unresolved resources
565           TreeMap<URL, URL> unresolvedResourcesSubDirs = new TreeMap<URL, URL>(PATH_COMPARATOR);
566           log("There were unresolved resources, which have been gathered into "
567                   + unresolvedResourcesDir, Project.MSG_INFO);
568           for(URL res : resources) {
569             URL resourceDir = null;
570             try {
571               resourceDir = new URL(res, ".");
572             }
573             catch(MalformedURLException e) {
574               throw new BuildException(
575                       "Can't construct URL to parent directory of " + res, e,
576                       getLocation());
577             }
578             URL targetDir =
579                     getUnresolvedResourcesTarget(unresolvedResourcesSubDirs,
580                             unresolvedResourcesDir, resourceDir);
581             String resName = res.getFile();
582             resName = resName.substring(resName.lastIndexOf('/'1);
583             URL newResourceURL = null;
584             try {
585               newResourceURL = new URL(targetDir, resName);
586             }
587             catch(MalformedURLException e) {
588               throw new BuildException("Can't construct URL relative to "
589                       + unresolvedResourcesDir + " for " + resName, e,
590                       getLocation());
591             }
592             gappModel.updatePathForURL(res, newResourceURL, true);
593             fileCopyMap.put(res, newResourceURL);
594             if(copyResourceDirs) {
595               dirCopyMap.put(resourceDir, targetDir);
596             }
597           }
598           break;
599 
600         default:
601           throw new BuildException("Unrecognised UnresolvedAction",
602                   getLocation());
603       }
604     }
605 
606     // write out the fixed GAPP file
607     try {
608       log("Writing modified gapp to " + destFile);
609       gappModel.write();
610     }
611     catch(IOException e) {
612       throw new BuildException("Error writing out modified GAPP file", e,
613               getLocation());
614     }
615 
616     // now copy the files that it references
617     if(fileCopyMap.size() 0) {
618       log("Copying " + fileCopyMap.size() " resources");
619     }
620     for(Map.Entry<URL, URL> resEntry : fileCopyMap.entrySet()) {
621       File source = Files.fileFromURL(resEntry.getKey());
622       File dest = Files.fileFromURL(resEntry.getValue());
623       if(source.isDirectory()) {
624         // source URL points to a directory, so create a corresponding
625         // directory dest
626         dest.mkdirs();
627       }
628       else {
629         // source URL doesn't point to a directory, so
630         // ensure parent directory exists
631         dest.getParentFile().mkdirs();
632         if(source.isFile()) {
633           // source URL points to an existing file, copy it
634           try {
635             log("Copying " + source + " to " + dest, Project.MSG_VERBOSE);
636             FileUtils.getFileUtils().copyFile(source, dest);
637           }
638           catch(IOException e) {
639             throw new BuildException(
640                     "Error copying file " + source + " to " + dest, e,
641                     getLocation());
642           }
643         }
644       }
645     }
646 
647     // handle the plugins
648     if(pluginCopyMap.size() 0) {
649       log("Copying " + pluginCopyMap.size() " plugins");
650       if(copyPlugins) {
651         log("Also copying complete plugin contents", Project.MSG_VERBOSE);
652       }
653       copyDirectories(pluginCopyMap, !copyPlugins);
654 
655       if(expandIvy) {
656         ExpandIvy ivyExpander = new ExpandIvy();
657         ivyExpander.setProject(getProject());
658         ivyExpander.setLocation(getLocation());
659         ivyExpander.setTaskName(getTaskName());
660         ivyExpander.setFully(!copyPlugins);
661 
662         for(URL url : pluginCopyMap.values()) {
663           File dir = Files.fileFromURL(url);
664           if(dir.exists()) {
665             ivyExpander.setDir(dir);
666             ivyExpander.init();
667             ivyExpander.perform();
668           }
669         }
670       }
671     }
672     
673     // handle extra directories
674     if(dirCopyMap.size() 0) {
675       log("Copying " + dirCopyMap.size() " resource directories");      
676       copyDirectories(dirCopyMap, false);
677     }
678     
679   }
680 
681   /**
682    * Process any extraresourcespath elements provided to this task and
683    * include the resources they refer to in the given set.
684    */
685   private void processExtraResourcesPaths(Set<URL> resources) {
686     for(Path p : extraResourcesPaths) {
687       for(String resource : p.list()) {
688         File resourceFile = new File(resource);
689         try {
690           resources.add(resourceFile.toURI().toURL());
691         }
692         catch(MalformedURLException e) {
693           throw new BuildException("Couldn't construct URL for extra resource "
694                   + resourceFile, e, getLocation());
695         }
696       }
697     }
698   }
699 
700   /**
701    * Copy directories as specified by the given map.
702    
703    @param copyMap map specifying the directories to copy and the
704    *          target locations to which they should be copied.
705    @param minimalPlugin if true, treat the directory as a GATE plugin
706    *          and copy just the minimal files needed for the plugin to
707    *          work (creole.xml and any referenced jars).
708    */
709   private void copyDirectories(Map<URL, URL> copyMap, boolean minimalPlugin) {
710     for(Map.Entry<URL, URL> copyEntry : copyMap.entrySet()) {
711       File source = Files.fileFromURL(copyEntry.getKey());
712       if(!source.exists()) { return}
713       File dest = Files.fileFromURL(copyEntry.getValue());
714       // set up a copy task to do the copying
715       Copy copyTask = new Copy();
716       copyTask.setProject(getProject());
717       copyTask.setLocation(getLocation());
718       copyTask.setTaskName(getTaskName());
719       copyTask.setTodir(dest);
720       // ensure the target directory exists
721       dest.mkdirs();
722       FileSet fileSet = new FileSet();
723       copyTask.addFileset(fileSet);
724       fileSet.setDir(source);
725       if(minimalPlugin) {
726         // just copy creole.xml and JARs
727         NameEntry include = fileSet.createInclude();
728         include.setName("creole.xml");
729         URL creoleXml;
730         try {
731           creoleXml =
732               new URL(copyEntry.getKey().toExternalForm() "/creole.xml");
733         catch(MalformedURLException e) {
734           throw new BuildException(
735               "Error creating URL for creole.xml in plugin "
736                   + copyEntry.getKey());
737         }
738 
739         for(String jarString : getJars(creoleXml)) {
740           NameEntry jarInclude = fileSet.createInclude();
741           jarInclude.setName(jarString);
742         }
743 
744         // copy the ivy files as either they will be needed to load the plugin
745         // or they will be needed when the expand task is run
746         try {
747           for(Element e : ExpandIvy.getIvyElements(creoleXml)) {
748             NameEntry ivyInclude = fileSet.createInclude();
749             ivyInclude.setName(ExpandIvy.getIvyPath(e));
750           }
751         catch(Exception e) {
752           throw new BuildException("Error processing IVY includes", e,
753               getLocation());
754         }
755       }
756 
757       // do the copying
758       copyTask.init();
759       copyTask.perform();
760     }
761   }
762 
763   /**
764    * Extract the text values from any &lt;JAR&gt; elements contained in the
765    * referenced creole.xml file.
766    
767    @return a set with one element for each unique &lt;JAR&gt; entry in the
768    *         given creole.xml.
769    */
770   private Set<String> getJars(URL creoleXml) {
771     try {
772       Set<String> jars = new HashSet<String>();
773       // the XPath is a bit ugly, but needed to match the element name
774       // case-insensitively.
775       XPath jarXPath =
776           XPath
777               .newInstance("//*[translate(local-name(), 'jar', 'JAR') = 'JAR']");
778       SAXBuilder builder = new SAXBuilder();
779       Document creoleDoc = builder.build(creoleXml);
780       // technically unsafe, but we know that the above XPath expression
781       // can only match elements.
782       @SuppressWarnings("unchecked")
783       List<Element> jarElts = jarXPath.selectNodes(creoleDoc);
784       for(Element e : jarElts) {
785         jars.add(e.getTextTrim());
786       }
787 
788       return jars;
789     catch(JDOMException e) {
790       throw new BuildException("Error extracting JAR elements from "
791           + creoleXml, e, getLocation());
792     catch(IOException e) {
793       throw new BuildException("Error loading " + creoleXml
794           " to extract JARs", e, getLocation());
795     }
796   }
797   
798   /**
799    * Get a URL for a directory to which the given (unresolved) resource
800    * directory should be mapped.
801    
802    @param unresolvedResourcesSubDirs a map from URLs of directories
803    *          containing unresolved resources to the URLs under the
804    *          target unresolved-resources directory that they will be
805    *          mapped to. This map is updated by this method.
806    @param unresolvedResourcesDir the top-level application-resources
807    *          directory in the target location.
808    @param resourceDir a directory containing an unresolved resource.
809    @return the URL under application-resources to which this directory
810    *         should be mapped. For a resourceDir of the form .../foo,
811    *         the returned URL would typically be
812    *         &lt;applicationResourcesDir&gt;/foo, but if a different
813    *         directory with the same name has already been mapped then
814    *         we will return the first ..../foo-2, foo-3, etc. that is
815    *         not already in use.  If one of the directory's ancestors
816    *         has already been mapped then we return a URL pointing
817    *         to the same relative path inside that ancestor's mapping,
818    *         e.g. if .../foo has already been mapped to a-r/foo-2 then
819    *         .../foo/bar/baz will map to a-r/foo-2/bar/baz.
820    */
821   private URL getUnresolvedResourcesTarget(
822           TreeMap<URL, URL> unresolvedResourcesSubDirs, URL unresolvedResourcesDir,
823           URL resourceDirthrows BuildException {
824     URL targetDir = unresolvedResourcesSubDirs.get(resourceDir);
825     try {
826       if(targetDir == null) {
827         // no exact match, try an ancestor match
828         SortedMap<URL, URL> possibleAncestors = unresolvedResourcesSubDirs.headMap(resourceDir);
829         URL nearestAncestor = null;
830         if(!possibleAncestors.isEmpty()) nearestAncestor = possibleAncestors.lastKey();
831         if(nearestAncestor != null && resourceDir.toExternalForm().startsWith(
832                 nearestAncestor.toExternalForm())) {
833           // found an ancestor mapping, so take the relative path
834           // from the ancestor to this dir and map it to the same
835           // path under the ancestor's mapping.
836           String relPath = PersistenceManager.getRelativePath(nearestAncestor, resourceDir);
837           targetDir = new URL(unresolvedResourcesSubDirs.get(nearestAncestor), relPath);
838         }
839         else {
840           // no ancestors currently mapped, so start a new sub-dir of
841           // unresolvedResourcesDir whose name is the last path
842           // component of the source URL
843           String resourcePath = resourceDir.getFile();
844           if(resourcePath.endsWith("/")) {
845             resourcePath = resourcePath.substring(0, resourcePath.length() 1);
846           }
847           String targetDirName =
848                   resourcePath.substring(resourcePath.lastIndexOf('/'1);
849           if(targetDirName.length() == 0) {
850             // edge case, if the source URL points to the root directory "/"
851             targetDirName = "resources";
852           }
853           // try application-resources/{targetDirName} as the target
854           targetDir = new URL(unresolvedResourcesDir, targetDirName + "/");
855           // if this is already taken, try
856           // application-resources/{targetDirName}-2,
857           // application-resources/{targetDirName}-3, etc., until we find
858           // one that is available.
859           if(unresolvedResourcesSubDirs.containsValue(targetDir)) {
860             int index = 2;
861             do {
862               targetDir =
863                       new URL(unresolvedResourcesDir, targetDirName + "-"
864                               (index++"/");
865             while(unresolvedResourcesSubDirs.containsValue(targetDir));
866           }
867         }
868         
869         // store the mapping for future use
870         unresolvedResourcesSubDirs.put(resourceDir, targetDir);
871       }
872     }
873     catch(MalformedURLException e) {
874       throw new BuildException("Can't construct target URL for directory "
875               + resourceDir, e, getLocation());
876     }
877 
878     return targetDir;
879   }
880 
881   /**
882    * Class to represent a nested <code>hint</code> element. Typically
883    * this will be a simple <code>&lt;hint from="X" to="Y" /&gt;</code>
884    * but the MappingHint class actually extends the property task, so it
885    * can read hints in Properties-file format using
886    <code>&lt;hint file="hints.properties" /&gt;</code>.
887    */
888   public class MappingHint extends Property {
889     private boolean absolute = false;
890 
891     public void setFrom(File from) {
892       super.setName(from.getAbsolutePath());
893     }
894 
895     public void setTo(String to) {
896       super.setValue(to);
897     }
898 
899     /**
900      * Should files matching this hint be made absolute? If true, the
901      * "to" value is ignored.
902      */
903     public void setAbsolute(boolean absolute) {
904       if(absolute) {
905         super.setValue("dummy");
906       }
907       this.absolute = absolute;
908     }
909 
910     /**
911      * This was the pre-ant 1.8 version of addProperty, Ant 1.8 uses the
912      * version that takes an <code>Object</code> as value rather than a
913      <code>String</code>.
914      */
915     @Override
916     protected void addProperty(String n, String v) {
917       addProperty(n, (Object)v);
918     }
919 
920     /**
921      * Rather than adding properties to the project, add mapping hints
922      * to the task.
923      */
924     @Override
925     protected void addProperty(String n, Object vObj) {
926       String v = (vObj == nullnull : vObj.toString();
927       try {
928         // resolve relative paths against project basedir
929         File source = getProject().resolveFile(n);
930         // add a trailing slash to the hint target if necessary
931         if(source.isDirectory() && v != null && !v.endsWith("/")) {
932           v += "/";
933         }
934         mappingHints.put(source.toURI().toURL(), absolute ? null : v);
935       }
936       catch(MalformedURLException e) {
937         PackageGappTask.this.log("Couldn't interpret \"" + n
938                 "\" as a file path, ignored", Project.MSG_WARN);
939       }
940     }
941 
942   }
943 }