001/*
002 * This library is part of OpenCms -
003 * the Open Source Content Management System
004 *
005 * Copyright (c) Alkacon Software GmbH (http://www.alkacon.com)
006 *
007 * This library is free software; you can redistribute it and/or
008 * modify it under the terms of the GNU Lesser General Public
009 * License as published by the Free Software Foundation; either
010 * version 2.1 of the License, or (at your option) any later version.
011 *
012 * This library is distributed in the hope that it will be useful,
013 * but WITHOUT ANY WARRANTY; without even the implied warranty of
014 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
015 * Lesser General Public License for more details.
016 *
017 * For further information about Alkacon Software GmbH, please see the
018 * company website: http://www.alkacon.com
019 *
020 * For further information about OpenCms, please see the
021 * project website: http://www.opencms.org
022 * 
023 * You should have received a copy of the GNU Lesser General Public
024 * License along with this library; if not, write to the Free Software
025 * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
026 */
027
028package org.opencms.util;
029
030import org.opencms.file.CmsObject;
031import org.opencms.file.CmsRequestContext;
032import org.opencms.file.CmsResource;
033import org.opencms.flex.CmsFlexCache;
034import org.opencms.i18n.CmsEncoder;
035import org.opencms.main.CmsException;
036import org.opencms.main.CmsIllegalArgumentException;
037import org.opencms.main.CmsSystemInfo;
038import org.opencms.staticexport.CmsLinkManager;
039
040import java.io.ByteArrayInputStream;
041import java.io.ByteArrayOutputStream;
042import java.io.File;
043import java.io.FileFilter;
044import java.io.FileInputStream;
045import java.io.FileNotFoundException;
046import java.io.FileOutputStream;
047import java.io.IOException;
048import java.io.InputStream;
049import java.io.OutputStreamWriter;
050import java.net.URL;
051import java.util.ArrayList;
052import java.util.Collections;
053import java.util.Iterator;
054import java.util.List;
055import java.util.ListIterator;
056import java.util.Locale;
057
058import org.apache.commons.collections.Closure;
059
060/**
061 * Provides File utility functions.<p>
062 * 
063 * @since 6.0.0 
064 */
065public final class CmsFileUtil {
066
067    /**
068     * Data bean which walkFileSystem passes to its callback.<p>
069     * 
070     * The list of directories is mutable, which can be used by the callback to exclude certain directories.<p> 
071     */
072    public static class FileWalkState {
073
074        /** Current directory. */
075        private File m_currentDir;
076
077        /** List of subdirectories of the current directory. */
078        private List<File> m_directories;
079
080        /** List of files of the current directory. */
081        private List<File> m_files;
082
083        /**
084         * Creates a new file walk state.<P>
085         * 
086         * @param currentDir the current directory 
087         * @param dirs the list of subdirectories 
088         * @param files the list of files 
089         */
090        public FileWalkState(File currentDir, List<File> dirs, List<File> files) {
091
092            m_currentDir = currentDir;
093            m_directories = dirs;
094            m_files = files;
095        }
096
097        /**
098         * Gets the current directory.<p>
099         * 
100         * @return the current directory 
101         */
102        public File getCurrentDir() {
103
104            return m_currentDir;
105        }
106
107        /**
108         * Gets the list of subdirectories.<p>
109         * 
110         * @return the list of subdirectories
111         */
112        public List<File> getDirectories() {
113
114            return m_directories;
115        }
116
117        /**
118         * Returns the list of files.<p>
119         * 
120         * @return the list of files 
121         */
122        public List<File> getFiles() {
123
124            return m_files;
125        }
126    }
127
128    /**
129     * Hides the public constructor.<p> 
130     */
131    private CmsFileUtil() {
132
133        // empty
134    }
135
136    /**
137     * Adds a trailing separator to a path if required.<p>
138     * 
139     * @param path the path to add the trailing separator to
140     * @return the path with a trailing separator
141     */
142    public static String addTrailingSeparator(String path) {
143
144        int l = path.length();
145        if ((l == 0) || (path.charAt(l - 1) != '/')) {
146            return path.concat("/");
147        } else {
148            return path;
149        }
150    }
151
152    /**
153     * Checks if all resources are present.<p>
154     * 
155     * @param cms an initialized OpenCms user context which must have read access to all resources
156     * @param resources a list of vfs resource names to check
157     * 
158     * @throws CmsIllegalArgumentException in case not all resources exist or can be read with the given OpenCms user context
159     */
160    public static void checkResources(CmsObject cms, List<String> resources) throws CmsIllegalArgumentException {
161
162        StringBuffer result = new StringBuffer(128);
163        ListIterator<String> it = resources.listIterator();
164        while (it.hasNext()) {
165            String resourcePath = it.next();
166            try {
167                CmsResource resource = cms.readResource(resourcePath);
168                // append folder separator, if resource is a folder and does not and with a slash
169                if (resource.isFolder() && !resourcePath.endsWith("/")) {
170                    it.set(resourcePath + "/");
171                }
172            } catch (CmsException e) {
173                result.append(resourcePath);
174                result.append('\n');
175            }
176        }
177        if (result.length() > 0) {
178            throw new CmsIllegalArgumentException(Messages.get().container(
179                Messages.ERR_MISSING_RESOURCES_1,
180                result.toString()));
181        }
182    }
183
184    /**
185     * Simply version of a 1:1 binary file copy.<p>
186     * 
187     * @param fromFile the name of the file to copy
188     * @param toFile the name of the target file
189     * @throws IOException if any IO error occurs during the copy operation
190     */
191    public static void copy(String fromFile, String toFile) throws IOException {
192
193        File inputFile = new File(fromFile);
194        File outputFile = new File(toFile);
195        if (!outputFile.getParentFile().isDirectory()) {
196            outputFile.getParentFile().mkdirs();
197        }
198        FileInputStream in = new FileInputStream(inputFile);
199        FileOutputStream out = new FileOutputStream(outputFile);
200
201        // transfer bytes from in to out
202        byte[] buf = new byte[1024];
203        int len;
204        while ((len = in.read(buf)) > 0) {
205            out.write(buf, 0, len);
206        }
207        in.close();
208        out.close();
209    }
210
211    /** 
212     * Returns the formatted filesize to Bytes, KB, MB or GB depending on the given value.<p>
213     * 
214     * @param filesize in bytes 
215     * @param locale the locale of the current OpenCms user or the System's default locale if the first choice 
216     *               is not at hand.
217     * 
218     * @return the formatted filesize to Bytes, KB, MB or GB depending on the given value 
219     **/
220    public static String formatFilesize(long filesize, Locale locale) {
221
222        String result;
223        filesize = Math.abs(filesize);
224
225        if (Math.abs(filesize) < 1024) {
226            result = Messages.get().getBundle(locale).key(Messages.GUI_FILEUTIL_FILESIZE_BYTES_1, new Long(filesize));
227        } else if (Math.abs(filesize) < 1048576) {
228            // 1048576 = 1024.0 * 1024.0
229            result = Messages.get().getBundle(locale).key(
230                Messages.GUI_FILEUTIL_FILESIZE_KBYTES_1,
231                new Double(filesize / 1024.0));
232        } else if (Math.abs(filesize) < 1073741824) {
233            // 1024.0^3 =  1073741824
234            result = Messages.get().getBundle(locale).key(
235                Messages.GUI_FILEUTIL_FILESIZE_MBYTES_1,
236                new Double(filesize / 1048576.0));
237        } else {
238            result = Messages.get().getBundle(locale).key(
239                Messages.GUI_FILEUTIL_FILESIZE_GBYTES_1,
240                new Double(filesize / 1073741824.0));
241        }
242        return result;
243    }
244
245    /**
246     * Returns a comma separated list of resource paths names, with the site root 
247     * from the given OpenCms user context removed.<p> 
248     * 
249     * @param context the current users OpenCms context (optional, may be <code>null</code>)
250     * @param resources a List of <code>{@link CmsResource}</code> instances to get the names from
251     * 
252     * @return a comma separated list of resource paths names
253     */
254    public static String formatResourceNames(CmsRequestContext context, List<CmsResource> resources) {
255
256        if (resources == null) {
257            return null;
258        }
259        StringBuffer result = new StringBuffer(128);
260        Iterator<CmsResource> i = resources.iterator();
261        while (i.hasNext()) {
262            CmsResource res = i.next();
263            String path = res.getRootPath();
264            if (context != null) {
265                path = context.removeSiteRoot(path);
266            }
267            result.append(path);
268            if (i.hasNext()) {
269                result.append(", ");
270            }
271        }
272        return result.toString();
273    }
274
275    /**
276     * Returns the extension of the given resource name, that is the part behind the last '.' char,
277     * converted to lower case letters.<p>
278     * 
279     * The extension of a file is the part of the name after the last dot, including the dot.
280     * The extension of a folder is empty.
281     * All extensions are returned as lower case<p>
282     * 
283     * Please note: No check is performed to ensure the given file name is not <code>null</code>.<p>
284     * 
285     * Examples:<br> 
286     * <ul>
287     *   <li><code>/folder.test/</code> has an empty extension.
288     *   <li><code>/folder.test/config</code> has an empty extension.
289     *   <li><code>/strange.filename.</code> has an empty extension.
290     *   <li><code>/document.PDF</code> has the extension <code>.pdf</code>.
291     * </ul>
292     * 
293     * @param resourceName the resource to get the extension for
294     * 
295     * @return the extension of a resource
296     */
297    public static String getExtension(String resourceName) {
298
299        // if the resource name indicates a folder
300        if (resourceName.charAt(resourceName.length() - 1) == '/') {
301            // folders have no extensions
302            return "";
303        }
304        // get just the name of the resource
305        String name = CmsResource.getName(resourceName);
306        // get the position of the last dot
307        int pos = name.lastIndexOf('.');
308        // if no dot or if no chars after the dot
309        if ((pos < 0) || ((pos + 1) == name.length())) {
310            return "";
311        }
312        // return the extension
313        return name.substring(pos).toLowerCase();
314    }
315
316    /**
317     * Returns the extension of the given file name, that is the part behind the last '.' char,
318     * converted to lower case letters.<p>
319     * 
320     * The result does contain the '.' char. For example, if the input is <code>"opencms.html"</code>,
321     * then the result will be <code>".html"</code>.<p>
322     * 
323     * If the given file name does not contain a '.' char, the empty String <code>""</code> is returned.<p>
324     * 
325     * Please note: No check is performed to ensure the given file name is not <code>null</code>.<p>
326     * 
327     * @param filename the file name to get the extension for
328     * @return the extension of the given file name
329     * 
330     * @deprecated use {@link #getExtension(String)} instead, it is better implemented
331     */
332    @Deprecated
333    public static String getFileExtension(String filename) {
334
335        int pos = filename.lastIndexOf('.');
336        return (pos >= 0) ? filename.substring(pos).toLowerCase() : "";
337    }
338
339    /**
340     * Returns a list of all filtered files in the RFS.<p>
341     * 
342     * If the <code>name</code> is not a folder the folder that contains the
343     * given file will be used instead.<p>
344     * 
345     * Despite the filter may not accept folders, every subfolder is traversed
346     * if the <code>includeSubtree</code> parameter is set.<p>
347     * 
348     * @param name a folder or file name
349     * @param filter a filter
350     * @param includeSubtree if to include subfolders
351     * 
352     * @return a list of filtered <code>{@link File}</code> objects
353     */
354    public static List<File> getFiles(String name, FileFilter filter, boolean includeSubtree) {
355
356        List<File> ret = new ArrayList<File>();
357
358        File file = new File(name);
359        if (!file.isDirectory()) {
360            file = new File(file.getParent());
361            if (!file.isDirectory()) {
362                return ret;
363            }
364        }
365        File[] dirContent = file.listFiles();
366        for (int i = 0; i < dirContent.length; i++) {
367            File f = dirContent[i];
368            if (filter.accept(f)) {
369                ret.add(f);
370            }
371            if (includeSubtree && f.isDirectory()) {
372                ret.addAll(getFiles(f.getAbsolutePath(), filter, true));
373            }
374        }
375
376        return ret;
377    }
378
379    /**
380     * Returns the file name for a given VFS name that has to be written to a repository in the "real" file system, 
381     * by appending the VFS root path to the given base repository path, also adding an 
382     * folder for the "online" or "offline" project.<p>
383     *
384     * @param repository the base repository path
385     * @param vfspath the VFS root path to write to use 
386     * @param online flag indicates if the result should be used for the online project (<code>true</code>) or not
387     * 
388     * @return The full uri to the JSP
389     */
390    public static String getRepositoryName(String repository, String vfspath, boolean online) {
391
392        StringBuffer result = new StringBuffer(64);
393        result.append(repository);
394        result.append(online ? CmsFlexCache.REPOSITORY_ONLINE : CmsFlexCache.REPOSITORY_OFFLINE);
395        result.append(vfspath);
396        return result.toString();
397    }
398
399    /**
400     * Creates unique, valid RFS name for the given filename that contains 
401     * a coded version of the given parameters, with the given file extension appended.<p>
402     *    
403     * This is used to create file names for the static export, 
404     * or in a vfs disk cache.<p>
405     * 
406     * @param filename the base file name
407     * @param extension the extension to use
408     * @param parameters the parameters to code in the result file name
409     * 
410     * @return a unique, valid RFS name for the given parameters
411     * 
412     * @see org.opencms.staticexport.CmsStaticExportManager
413     */
414    public static String getRfsPath(String filename, String extension, String parameters) {
415
416        StringBuffer buf = new StringBuffer(128);
417        buf.append(filename);
418        buf.append('_');
419        int h = parameters.hashCode();
420        // ensure we do have a positive id value
421        buf.append(h > 0 ? h : -h);
422        buf.append(extension);
423        return buf.toString();
424    }
425
426    /**
427     * Normalizes a file path that might contain <code>'../'</code> or <code>'./'</code> or <code>'//'</code> 
428     * elements to a normal absolute path, the path separator char used is {@link File#separatorChar}.<p>
429     * 
430     * @param path the path to normalize
431     * 
432     * @return the normalized path
433     * 
434     * @see #normalizePath(String, char)
435     */
436    public static String normalizePath(String path) {
437
438        return normalizePath(path, File.separatorChar);
439    }
440
441    /**
442     * Normalizes a file path that might contain <code>'../'</code> or <code>'./'</code> or <code>'//'</code> 
443     * elements to a normal absolute path.<p>
444     * 
445     * Can also handle Windows like path information containing a drive letter, 
446     * like <code>C:\path\..\</code>.<p>
447     * 
448     * @param path the path to normalize
449     * @param separatorChar the file separator char to use, for example {@link File#separatorChar}
450     * 
451     * @return the normalized path
452     */
453    public static String normalizePath(String path, char separatorChar) {
454
455        if (CmsStringUtil.isNotEmpty(path)) {
456
457            // handle windows paths including drive-information first
458            String drive = null;
459            if ((path.length() > 1) && (path.charAt(1) == ':')) {
460                // windows path like C:\home\
461                drive = path.substring(0, 2);
462                path = path.substring(2);
463            } else if ((path.length() > 1) && (path.charAt(0) == '\\') && (path.charAt(1) == '\\')) {
464                // windows path like \\home\ (network mapped drives)
465                drive = path.substring(0, 2);
466                path = path.substring(2);
467            }
468            // ensure all File separators are '/'
469            path = path.replace('\\', '/');
470            if (drive != null) {
471                drive = drive.replace('\\', '/');
472            }
473            if (path.charAt(0) == '/') {
474                // trick to resolve all ../ inside a path
475                path = '.' + path;
476            }
477            // resolve all '../' or './' elements in the path
478            path = CmsLinkManager.getAbsoluteUri(path, "/");
479            // still some '//' elements might persist
480            path = CmsStringUtil.substitute(path, "//", "/");
481            // re-append drive if required
482            if (drive != null) {
483                path = drive.concat(path);
484            }
485            // switch '/' back to OS dependend File separator if required
486            if (separatorChar != '/') {
487                path = path.replace('/', separatorChar);
488            }
489        }
490        return path;
491    }
492
493    /**
494     * Returns the normalized file path created from the given URL.<p>
495     * 
496     * The path part {@link URL#getPath()} is used, unescaped and 
497     * normalized using {@link #normalizePath(String, char)} using {@link File#separatorChar}.<p>
498     * 
499     * @param url the URL to extract the path information from
500     * 
501     * @return the normalized file path created from the given URL using {@link File#separatorChar}
502     * 
503     * @see #normalizePath(URL, char)
504     */
505    public static String normalizePath(URL url) {
506
507        return normalizePath(url, File.separatorChar);
508    }
509
510    /**
511     * Returns the normalized file path created from the given URL.<p>
512     * 
513     * The path part {@link URL#getPath()} is used, unescaped and 
514     * normalized using {@link #normalizePath(String, char)}.<p>
515     * 
516     * @param url the URL to extract the path information from
517     * @param separatorChar the file separator char to use, for example {@link File#separatorChar}
518     * 
519     * @return the normalized file path created from the given URL
520     */
521    public static String normalizePath(URL url, char separatorChar) {
522
523        // get the path part from the URL
524        String path = new File(url.getPath()).getAbsolutePath();
525        // trick to get the OS default encoding, taken from the official Java i18n FAQ
526        String systemEncoding = (new OutputStreamWriter(new ByteArrayOutputStream())).getEncoding();
527        // decode url in order to remove spaces and escaped chars from path
528        return CmsFileUtil.normalizePath(CmsEncoder.decode(path, systemEncoding), separatorChar);
529    }
530
531    /**
532     * Deletes a directory in the file system and all subfolders of that directory.<p>
533     * 
534     * @param directory the directory to delete
535     */
536    public static void purgeDirectory(File directory) {
537
538        if (directory.canRead() && directory.isDirectory()) {
539            File[] files = directory.listFiles();
540            for (int i = 0; i < files.length; i++) {
541                File f = files[i];
542                if (f.isDirectory()) {
543                    purgeDirectory(f);
544                }
545                if (f.canWrite()) {
546                    f.delete();
547                }
548            }
549            directory.delete();
550        }
551    }
552
553    /**
554     * Reads a file from the RFS and returns the file content.<p> 
555     * 
556     * @param file the file to read 
557     * @return the read file content
558     * 
559     * @throws IOException in case of file access errors
560     */
561    public static byte[] readFile(File file) throws IOException {
562
563        // create input and output stream
564        FileInputStream in = new FileInputStream(file);
565
566        // read the content
567        return readFully(in, (int)file.length());
568    }
569
570    /**
571     * Reads a file with the given name from the class loader and returns the file content.<p> 
572     * 
573     * @param filename the file to read 
574     * @return the read file content
575     * 
576     * @throws IOException in case of file access errors
577     */
578    public static byte[] readFile(String filename) throws IOException {
579
580        // create input and output stream
581        InputStream in = CmsFileUtil.class.getClassLoader().getResourceAsStream(filename);
582        if (in == null) {
583            throw new FileNotFoundException(filename);
584        }
585
586        return readFully(in);
587    }
588
589    /**
590     * Reads a file from the class loader and converts it to a String with the specified encoding.<p> 
591     * 
592     * @param filename the file to read 
593     * @param encoding the encoding to use when converting the file content to a String
594     * @return the read file convered to a String
595     * @throws IOException in case of file access errors
596     */
597    public static String readFile(String filename, String encoding) throws IOException {
598
599        return new String(readFile(filename), encoding);
600    }
601
602    /**
603     * Reads all bytes from the given input stream, closes it
604     * and returns the result in an array.<p> 
605     * 
606     * @param in the input stream to read the bytes from 
607     * @return the byte content of the input stream
608     * 
609     * @throws IOException in case of errors in the underlying java.io methods used
610     */
611    public static byte[] readFully(InputStream in) throws IOException {
612
613        return readFully(in, true);
614    }
615
616    /**
617     * Reads all bytes from the given input stream, conditionally closes the given input stream 
618     * and returns the result in an array.<p> 
619     * 
620     * @param in the input stream to read the bytes from 
621     * @return the byte content of the input stream
622     * @param closeInputStream if true the given stream will be closed afterwards
623     * 
624     * @throws IOException in case of errors in the underlying java.io methods used
625     */
626    public static byte[] readFully(InputStream in, boolean closeInputStream) throws IOException {
627
628        if (in instanceof ByteArrayInputStream) {
629            // content can be read in one pass
630            return readFully(in, in.available(), closeInputStream);
631        }
632
633        // copy buffer
634        byte[] xfer = new byte[2048];
635        // output buffer
636        ByteArrayOutputStream out = new ByteArrayOutputStream(xfer.length);
637
638        // transfer data from input to output in xfer-sized chunks.
639        for (int bytesRead = in.read(xfer, 0, xfer.length); bytesRead >= 0; bytesRead = in.read(xfer, 0, xfer.length)) {
640            if (bytesRead > 0) {
641                out.write(xfer, 0, bytesRead);
642            }
643        }
644        if (closeInputStream) {
645            in.close();
646        }
647        out.close();
648        return out.toByteArray();
649    }
650
651    /**
652     * Reads the specified number of bytes from the given input stream and returns the result in an array.<p> 
653     * 
654     * @param in the input stream to read the bytes from
655     * @param size the number of bytes to read 
656     *  
657     * @return the byte content read from the input stream
658     * 
659     * @throws IOException in case of errors in the underlying java.io methods used
660     */
661    public static byte[] readFully(InputStream in, int size) throws IOException {
662
663        return readFully(in, size, true);
664    }
665
666    /**
667     * Reads the specified number of bytes from the given input stream, conditionally closes the stream 
668     * and returns the result in an array.<p> 
669     * 
670     * @param in the input stream to read the bytes from
671     * @param size the number of bytes to read 
672     * @param closeStream if true the given stream will be closed 
673     *  
674     * @return the byte content read from the input stream
675     * 
676     * @throws IOException in case of errors in the underlying java.io methods used
677     */
678    public static byte[] readFully(InputStream in, int size, boolean closeStream) throws IOException {
679
680        // create the byte array to hold the data
681        byte[] bytes = new byte[size];
682
683        // read in the bytes
684        int offset = 0;
685        int numRead = 0;
686        while (offset < size) {
687            numRead = in.read(bytes, offset, size - offset);
688            if (numRead >= 0) {
689                offset += numRead;
690            } else {
691                break;
692            }
693        }
694
695        // close the input stream and return bytes
696        if (closeStream) {
697            in.close();
698        }
699
700        // ensure all the bytes have been read in
701        if (offset < bytes.length) {
702            throw new IOException("Could not read requested " + size + " bytes from input stream");
703        }
704
705        return bytes;
706    }
707
708    /** 
709     * Removes all resource names in the given List that are "redundant" because the parent folder name 
710     * is also contained in the List.<p> 
711     * 
712     * The content of the input list is not modified.<p>
713     * 
714     * @param resourcenames a list of VFS pathnames to check for redundencies (Strings)
715     *  
716     * @return a new list with all redundancies removed
717     * 
718     * @see #removeRedundantResources(List)
719     */
720    public static List<String> removeRedundancies(List<String> resourcenames) {
721
722        if ((resourcenames == null) || (resourcenames.isEmpty())) {
723            return new ArrayList<String>();
724        }
725        if (resourcenames.size() == 1) {
726            // if there is only one resource name in the list, there can be no redundancies
727            return new ArrayList<String>(resourcenames);
728        }
729        // check all resources names and see if a parent folder name is contained
730        List<String> result = new ArrayList<String>(resourcenames.size());
731        List<String> base = new ArrayList<String>(resourcenames);
732        Collections.sort(base);
733        Iterator<String> i = base.iterator();
734        while (i.hasNext()) {
735            // check all resource names in the list
736            String resourcename = i.next();
737            if (CmsStringUtil.isEmptyOrWhitespaceOnly(resourcename)) {
738                // skip empty strings
739                continue;
740            }
741            boolean valid = true;
742            for (int j = (result.size() - 1); j >= 0; j--) {
743                // check if this resource name is indirectly contained because a parent folder name is contained
744                String check = result.get(j);
745                if ((CmsResource.isFolder(check) && resourcename.startsWith(check)) || resourcename.equals(check)) {
746                    valid = false;
747                    break;
748                }
749            }
750            if (valid) {
751                // a parent folder name is not already contained in the result
752                result.add(resourcename);
753            }
754        }
755        return result;
756    }
757
758    /** 
759     * Removes all resources in the given List that are "redundant" because the parent folder 
760     * is also contained in the List.<p> 
761     * 
762     * The content of the input list is not modified.<p>
763     * 
764     * @param resources a list of <code>{@link CmsResource}</code> objects to check for redundancies
765     *  
766     * @return a the given list with all redundancies removed
767     * 
768     * @see #removeRedundancies(List)
769     */
770    public static List<CmsResource> removeRedundantResources(List<CmsResource> resources) {
771
772        if ((resources == null) || (resources.isEmpty())) {
773            return new ArrayList<CmsResource>();
774        }
775        if (resources.size() == 1) {
776            // if there is only one resource in the list, there can be no redundancies
777            return new ArrayList<CmsResource>(resources);
778        }
779        // check all resources and see if a parent folder name is contained
780        List<CmsResource> result = new ArrayList<CmsResource>(resources.size());
781        List<CmsResource> base = new ArrayList<CmsResource>(resources);
782        Collections.sort(base);
783        Iterator<CmsResource> i = base.iterator();
784        while (i.hasNext()) {
785            // check all folders in the list
786            CmsResource resource = i.next();
787            boolean valid = true;
788            for (int j = (result.size() - 1); j >= 0; j--) {
789                // check if this resource is indirectly contained because a parent folder is contained
790                CmsResource check = result.get(j);
791                if ((check.isFolder() && resource.getRootPath().startsWith(check.getRootPath()))
792                    || resource.getRootPath().equals(check.getRootPath())) {
793                    valid = false;
794                    break;
795                }
796            }
797            if (valid) {
798                // the parent folder is not already contained in the result
799                result.add(resource);
800            }
801        }
802        return result;
803    }
804
805    /**
806     * Removes a trailing separator from a path if required.<p>
807     * 
808     * In case we have the root folder "/", the separator is not removed.<p>
809     * 
810     * @param path the path to remove the trailing separator from
811     * @return the path without a trailing separator
812     */
813    public static String removeTrailingSeparator(String path) {
814
815        int l = path.length();
816        if ((l <= 1) || (path.charAt(l - 1) != '/')) {
817            return path;
818        } else {
819            return path.substring(0, l - 1);
820        }
821    }
822
823    /**
824     * Searches for the OpenCms web application 'WEB-INF' folder during system startup, code or 
825     * <code>null</code> if the 'WEB-INF' folder can not be found.<p>
826     * 
827     * @param startFolder the folder where to start searching
828     * 
829     * @return String the path of the 'WEB-INF' folder in the 'real' file system, or <code>null</code>
830     */
831    public static String searchWebInfFolder(String startFolder) {
832
833        if (CmsStringUtil.isEmpty(startFolder)) {
834            return null;
835        }
836
837        File f = new File(startFolder);
838        if (!f.exists() || !f.isDirectory()) {
839            return null;
840        }
841
842        File configFile = new File(f, CmsSystemInfo.FILE_TLD);
843        if (configFile.exists() && configFile.isFile()) {
844            return f.getAbsolutePath();
845        }
846
847        String webInfFolder = null;
848        File[] subFiles = f.listFiles();
849        for (int i = 0; i < subFiles.length; i++) {
850            if (subFiles[i].isDirectory()) {
851                webInfFolder = searchWebInfFolder(subFiles[i].getAbsolutePath());
852                if (webInfFolder != null) {
853                    break;
854                }
855            }
856        }
857
858        return webInfFolder;
859    }
860
861    /**
862     * Traverses the file system starting from a base folder and executes a callback for every directory found.<p>
863     * 
864     * @param base the base folder 
865     * @param action a callback which will be passed a FileWalkState object for every directory encountered 
866     */
867    public static void walkFileSystem(File base, Closure action) {
868
869        List<FileWalkState> m_states = new ArrayList<FileWalkState>();
870        m_states.add(createFileWalkState(base));
871        while (!m_states.isEmpty()) {
872            // pop the top off the state stack, process it, then push states for all subdirectories onto it 
873            FileWalkState last = m_states.remove(m_states.size() - 1);
874            action.execute(last);
875            for (File dir : last.getDirectories()) {
876                m_states.add(createFileWalkState(dir));
877            }
878        }
879    }
880
881    /**
882     * Helper method for creating a FileWalkState object from a File object.<p>
883     * 
884     * @param file the file 
885     * 
886     * @return the file walk state 
887     */
888    private static FileWalkState createFileWalkState(File file) {
889
890        File[] contents = file.listFiles();
891        List<File> dirs = new ArrayList<File>();
892        List<File> files = new ArrayList<File>();
893        for (File subFile : contents) {
894            if (subFile.isDirectory()) {
895                dirs.add(subFile);
896            } else {
897                files.add(subFile);
898            }
899        }
900        return new FileWalkState(file, dirs, files);
901    }
902}