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, 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.loader;
029
030import org.opencms.file.CmsObject;
031import org.opencms.file.CmsProject;
032import org.opencms.file.CmsResource;
033import org.opencms.file.CmsResourceFilter;
034import org.opencms.main.CmsException;
035import org.opencms.main.CmsLog;
036import org.opencms.main.OpenCms;
037import org.opencms.util.CmsFileUtil;
038import org.opencms.util.CmsMacroResolver;
039import org.opencms.util.CmsStringUtil;
040import org.opencms.util.I_CmsMacroResolver;
041import org.opencms.util.PrintfFormat;
042import org.opencms.workplace.CmsWorkplace;
043import org.opencms.xml.content.CmsNumberSuffixNameSequence;
044
045import java.util.HashSet;
046import java.util.Iterator;
047import java.util.List;
048import java.util.Set;
049
050import org.apache.commons.collections.Factory;
051import org.apache.commons.logging.Log;
052
053/**
054 * The default class used for generating file names either for the <code>urlName</code> mapping
055 * or when using a "new" operation in the context of the direct edit interface.<p>
056 *
057 * @since 8.0.0
058 */
059public class CmsDefaultFileNameGenerator implements I_CmsFileNameGenerator {
060
061    /**
062     * Factory to use for resolving the %(number) macro.<p>
063     */
064    public class CmsNumberFactory implements Factory {
065
066        /** The actual number. */
067        protected int m_number;
068
069        /** Format for file create parameter. */
070        protected PrintfFormat m_numberFormat;
071
072        /**
073         * Create a new number factory.<p>
074         *
075         * @param digits the number of digits to use
076         */
077        public CmsNumberFactory(int digits) {
078
079            m_numberFormat = new PrintfFormat("%0." + digits + "d");
080            m_number = 0;
081        }
082
083        /**
084         * Create the number based on the number of digits set.<p>
085         *
086         * @see org.apache.commons.collections.Factory#create()
087         */
088        public Object create() {
089
090            // formats the number with the amount of digits selected
091            return m_numberFormat.sprintf(m_number);
092        }
093
094        /**
095         * Sets the number to create to the given value.<p>
096         *
097         * @param number the number to set
098         */
099        public void setNumber(int number) {
100
101            m_number = number;
102        }
103    }
104
105    private static final Log LOG = CmsLog.getLog(CmsDefaultFileNameGenerator.class);
106
107    /** Start sequence for macro with digits. */
108    private static final String MACRO_NUMBER_DIGIT_SEPARATOR = ":";
109
110    /** The copy file name insert. */
111    public static final String COPY_FILE_NAME_INSERT = "-copy";
112
113    /** CMS context with admin rights. */
114    private CmsObject m_adminCms;
115
116    /**
117     * Checks the given pattern for the number macro.<p>
118     *
119     * @param pattern the pattern to check
120     *
121     * @return <code>true</code> if the pattern contains the macro
122     */
123    public static boolean hasNumberMacro(String pattern) {
124
125        // check both macro variants
126        return hasNumberMacro(
127            pattern,
128            "" + I_CmsMacroResolver.MACRO_DELIMITER + I_CmsMacroResolver.MACRO_START,
129            "" + I_CmsMacroResolver.MACRO_END)
130            || hasNumberMacro(
131                pattern,
132                "" + I_CmsMacroResolver.MACRO_DELIMITER_OLD + I_CmsMacroResolver.MACRO_START_OLD,
133                "" + I_CmsMacroResolver.MACRO_END_OLD);
134    }
135
136    /**
137     * Removes the file extension if it only consists of letters.<p>
138     *
139     * @param path the path from which to remove the file extension
140     *
141     * @return the path without the file extension
142     */
143    public static String removeExtension(String path) {
144
145        return path.replaceFirst("\\.[a-zA-Z]*$", "");
146    }
147
148    /**
149     * Checks the given pattern for the number macro.<p>
150     *
151     * @param pattern the pattern to check
152     * @param macroStart the macro start string
153     * @param macroEnd the macro end string
154     *
155     * @return <code>true</code> if the pattern contains the macro
156     */
157    private static boolean hasNumberMacro(String pattern, String macroStart, String macroEnd) {
158
159        String macro = I_CmsFileNameGenerator.MACRO_NUMBER;
160        String macroPart = macroStart + macro + MACRO_NUMBER_DIGIT_SEPARATOR;
161        int prefixIndex = pattern.indexOf(macroPart);
162        if (prefixIndex >= 0) {
163            // this macro contains an individual digit setting
164            char n = pattern.charAt(prefixIndex + macroPart.length());
165            macro = macro + MACRO_NUMBER_DIGIT_SEPARATOR + n;
166        }
167        return pattern.contains(macroStart + macro + macroEnd);
168    }
169
170    /**
171     * @see org.opencms.loader.I_CmsFileNameGenerator#getCopyFileName(org.opencms.file.CmsObject, java.lang.String, java.lang.String)
172     */
173    public String getCopyFileName(CmsObject cms, String parentFolder, String baseName) {
174
175        String name = baseName;
176        int dot = name.lastIndexOf(".");
177        if (dot > 0) {
178            if (!name.substring(0, dot).endsWith(COPY_FILE_NAME_INSERT)) {
179                name = name.substring(0, dot) + COPY_FILE_NAME_INSERT + name.substring(dot);
180            }
181        } else {
182            if (!name.endsWith(COPY_FILE_NAME_INSERT)) {
183                name += COPY_FILE_NAME_INSERT;
184            }
185        }
186        return getUniqueFileName(cms, parentFolder, name);
187    }
188
189    /**
190     * @see org.opencms.loader.I_CmsFileNameGenerator#getNewFileName(org.opencms.file.CmsObject, java.lang.String, int)
191     */
192    public String getNewFileName(CmsObject cms, String namePattern, int defaultDigits) throws CmsException {
193
194        return getNewFileName(cms, namePattern, defaultDigits, false);
195    }
196
197    /**
198     * Returns a new resource name based on the provided OpenCms user context and name pattern.<p>
199     *
200     * The pattern in this default implementation must be a path which may contain the macro <code>%(number)</code>.
201     * This will be replaced by the first "n" digit sequence for which the resulting file name is not already
202     * used. For example the pattern <code>"/file_%(number).xml"</code> would result in something like <code>"/file_00003.xml"</code>.<p>
203     *
204     * Alternatively, the macro can have the form <code>%(number:n)</code> with <code>n = {1...9}</code>, for example <code>%(number:6)</code>.
205     * In this case the default digits will be ignored and instead the digits provided as "n" will be used.<p>
206     *
207     * @param cms the current OpenCms user context
208     * @param namePattern the  pattern to be used when generating the new resource name
209     * @param defaultDigits the default number of digits to use for numbering the created file names
210     * @param explorerMode if true, the file name is first tried without a numeric macro, also underscores are inserted automatically before the number macro and don't need to be part of the name pattern
211     *
212     * @return a new resource name based on the provided OpenCms user context and name pattern
213     *
214     * @throws CmsException in case something goes wrong
215     */
216    public String getNewFileName(CmsObject userCms, String namePattern, int defaultDigits, boolean explorerMode)
217    throws CmsException {
218
219        CmsObject cms = OpenCms.initCmsObject(m_adminCms);
220        cms.getRequestContext().setSiteRoot(userCms.getRequestContext().getSiteRoot());
221        cms.getRequestContext().setCurrentProject(userCms.getRequestContext().getCurrentProject());
222        String checkPattern = cms.getRequestContext().removeSiteRoot(namePattern);
223        String folderName = CmsResource.getFolderPath(checkPattern);
224
225        // must check ALL resources in folder because name doesn't care for type
226        List<CmsResource> resources = cms.readResources(folderName, CmsResourceFilter.ALL, false);
227        CmsObject onlineCms = OpenCms.initCmsObject(cms);
228        onlineCms.getRequestContext().setCurrentProject(cms.readProject(CmsProject.ONLINE_PROJECT_ID));
229
230        // now create a list of all the file names
231        Set<String> fileNames = new HashSet<>();
232        for (CmsResource res : resources) {
233            fileNames.add(cms.getSitePath(res));
234        }
235
236        try {
237            CmsResource offlineFolder = cms.readResource(folderName);
238            CmsResource onlineFolder = onlineCms.readResource(offlineFolder.getStructureId());
239            String onlinePath = onlineCms.getSitePath(onlineFolder);
240            List<CmsResource> onlineContents = onlineCms.readResources(onlinePath, CmsResourceFilter.ALL, false);
241            for (CmsResource res : onlineContents) {
242                fileNames.add(cms.getSitePath(res));
243            }
244
245        } catch (CmsException e) {
246            LOG.warn(e.getLocalizedMessage(), e);
247        }
248
249        return getNewFileNameFromList(fileNames, checkPattern, defaultDigits, explorerMode);
250    }
251
252    /**
253     * @see org.opencms.loader.I_CmsFileNameGenerator#getUniqueFileName(org.opencms.file.CmsObject, java.lang.String, java.lang.String)
254     */
255    public String getUniqueFileName(CmsObject cms, String parentFolder, String baseName) {
256
257        String translatedTitle = OpenCms.getResourceManager().getFileTranslator().translateResource(baseName).replace(
258            "/",
259            "-");
260        Iterator<String> nameIterator = new CmsNumberSuffixNameSequence(translatedTitle, true);
261        String result = nameIterator.next();
262        CmsObject onlineCms = null;
263
264        try {
265            onlineCms = OpenCms.initCmsObject(cms);
266            onlineCms.getRequestContext().setCurrentProject(cms.readProject(CmsProject.ONLINE_PROJECT_ID));
267        } catch (CmsException e) {
268            // should not happen, nothing to do
269        }
270        String path = CmsStringUtil.joinPaths(parentFolder, result);
271        // use CmsResourceFilter.ALL because we also want to skip over deleted resources
272        while (cms.existsResource(path, CmsResourceFilter.ALL)
273            || ((onlineCms != null) && onlineCms.existsResource(path, CmsResourceFilter.ALL))) {
274            result = nameIterator.next();
275            path = CmsStringUtil.joinPaths(parentFolder, result);
276        }
277        return result;
278    }
279
280    /**
281     * This default implementation will just generate a 5 digit sequence that is appended to the resource name in case
282     * of a collision of names.<p>
283     *
284     * @see org.opencms.loader.I_CmsFileNameGenerator#getUrlNameSequence(java.lang.String)
285     */
286    public Iterator<String> getUrlNameSequence(String baseName) {
287
288        String translatedTitle = OpenCms.getResourceManager().getFileTranslator().translateResource(baseName).replace(
289            "/",
290            "-");
291        return new CmsNumberSuffixNameSequence(translatedTitle, false);
292    }
293
294    /**
295     * @see org.opencms.loader.I_CmsFileNameGenerator#setAdminCms(org.opencms.file.CmsObject)
296     */
297    public void setAdminCms(CmsObject cms) {
298
299        if (m_adminCms == null) {
300            m_adminCms = cms;
301        }
302    }
303
304    /**
305     * Internal method for file name generation, decoupled for testing.<p>
306     *
307     * @param fileNames the list of file names already existing in the folder
308     * @param checkPattern the pattern to be used when generating the new resource name
309     * @param defaultDigits the default number of digits to use for numbering the created file names
310     * @param explorerMode if true, first the file name without a number is tried, and also an underscore is automatically inserted before the number macro
311     *
312     * @return a new resource name based on the provided OpenCms user context and name pattern
313     */
314    protected String getNewFileNameFromList(
315        Set<String> fileNames,
316        String checkPattern,
317        int defaultDigits,
318        final boolean explorerMode) {
319
320        if (!hasNumberMacro(checkPattern)) {
321            throw new IllegalArgumentException(
322                Messages.get().getBundle().key(Messages.ERR_FILE_NAME_PATTERN_WITHOUT_NUMBER_MACRO_1, checkPattern));
323        }
324
325        String checkFileName, checkTempFileName;
326        CmsMacroResolver resolver = CmsMacroResolver.newInstance();
327        Set<String> extensionlessNames = new HashSet<String>();
328        for (String name : fileNames) {
329            if (name.length() > 1) {
330                name = CmsFileUtil.removeTrailingSeparator(name);
331            }
332            extensionlessNames.add(removeExtension(name));
333        }
334
335        String macro = I_CmsFileNameGenerator.MACRO_NUMBER;
336        int useDigits = defaultDigits;
337        String macroStart = ""
338            + I_CmsMacroResolver.MACRO_DELIMITER
339            + I_CmsMacroResolver.MACRO_START
340            + macro
341            + MACRO_NUMBER_DIGIT_SEPARATOR;
342        int prefixIndex = checkPattern.indexOf(macroStart);
343        if (prefixIndex < 0) {
344            macroStart = ""
345                + I_CmsMacroResolver.MACRO_DELIMITER_OLD
346                + I_CmsMacroResolver.MACRO_START_OLD
347                + macro
348                + MACRO_NUMBER_DIGIT_SEPARATOR;
349            prefixIndex = checkPattern.indexOf(macroStart);
350        }
351        if (prefixIndex >= 0) {
352            // this macro contains an individual digit setting
353            char n = checkPattern.charAt(prefixIndex + macroStart.length());
354            macro = macro + ':' + n;
355            useDigits = Character.getNumericValue(n);
356        }
357
358        CmsNumberFactory numberFactory = new CmsNumberFactory(useDigits) {
359
360            @Override
361            public Object create() {
362
363                if (explorerMode) {
364                    if (m_number == 1) {
365                        return "";
366                    } else {
367                        return "_" + m_numberFormat.sprintf(m_number - 1);
368                    }
369                } else {
370                    return super.create();
371                }
372            }
373
374        };
375        resolver.addDynamicMacro(macro, numberFactory);
376        Set<String> checked = new HashSet<String>();
377        int j = 0;
378        do {
379            numberFactory.setNumber(++j);
380            // resolve macros in file name
381            checkFileName = resolver.resolveMacros(checkPattern);
382            if (checked.contains(checkFileName)) {
383                // the file name has been checked before, abort the search
384                throw new RuntimeException(
385                    Messages.get().getBundle().key(Messages.ERR_NO_FILE_NAME_AVAILABLE_FOR_PATTERN_1, checkPattern));
386            }
387            checked.add(checkFileName);
388            // get name of the resolved temp file
389            checkTempFileName = CmsWorkplace.getTemporaryFileName(checkFileName);
390        } while (extensionlessNames.contains(removeExtension(checkFileName))
391            || extensionlessNames.contains(removeExtension(checkTempFileName)));
392
393        return checkFileName;
394    }
395
396}