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