001/*
002 *  Copyright 2023 Anyware Services
003 *
004 *  Licensed under the Apache License, Version 2.0 (the "License");
005 *  you may not use this file except in compliance with the License.
006 *  You may obtain a copy of the License at
007 *
008 *      http://www.apache.org/licenses/LICENSE-2.0
009 *
010 *  Unless required by applicable law or agreed to in writing, software
011 *  distributed under the License is distributed on an "AS IS" BASIS,
012 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 *  See the License for the specific language governing permissions and
014 *  limitations under the License.
015 */
016
017package org.ametys.plugins.repository.jcr;
018
019import java.text.Normalizer;
020import java.util.regex.Matcher;
021import java.util.regex.Pattern;
022
023import org.apache.jackrabbit.util.Text;
024
025import org.ametys.core.util.StringUtils;
026import org.ametys.plugins.repository.TraversableAmetysObject;
027
028/**
029 * Helper for implementing {@link TraversableAmetysObject} stored in JCR.
030 */
031public final class NameHelper
032{
033    private static final Pattern __NODE_NAME_PATTERN = Pattern.compile("^([0-9-_]*)[a-z].*$");
034    
035    /**
036     * Mode of computation for the name if it already exists in JCR.
037     */
038    public enum NameComputationMode
039    {
040        /**
041         * Use the legacy incremental mode: add a suffix which is automatically increment while the node name already exists.
042         * Prefix: [node name in lower case without special characters]
043         * Suffix: -[incremental number]
044         */
045        INCREMENTAL,
046
047        /**
048         * Use {@link StringUtils}.generatedKey() method to create a random suffix.
049         * Prefix: [node name in lower case without special characters]
050         * Suffix: -[code with 8 lower-case alphanumerics characters]
051         */
052        GENERATED_KEY,
053
054        /**
055         * Use for user-friendly pattern as: Non-formatted title (incremented number).
056         * Prefix: [given name]
057         * Suffix: [space character]([incremental number])
058         */
059        USER_FRIENDLY
060    }
061    
062    // group 1 is based name, group 2 is not captured it is the suffix (optional)
063    
064    // my-name-is-robby, my-name-is-robby-azerty12, my-name-is-robby-qwerty45 (group 1: my-name-is-robby)
065    private static final Pattern __NAME_GENERATED_KEY_PATTERN = Pattern.compile("^(.+?)(?:-[0-9a-z]{8})?$");
066    // my-name-is-robby, my-name-is-robby-1, my-name-is-robby-2 (group 1: my-name-is-robby)
067    private static final Pattern __NAME_INCREMENTAL_PATTERN = Pattern.compile("^(.+?)(?:-[0-9]+)?$");
068    // My name is Robby, My name is Robby (1), My name is Robby (2) (group 1: My name is Robby)
069    private static final Pattern __NAME_USER_FRIENDLY_PATTERN = Pattern.compile("^(.+?)(?: \\([0-9]+\\))?$");
070    
071    private NameHelper()
072    {
073        // empty
074    }
075    
076    /**
077     * Filter a name for using it into an URI.
078     * @param name the name to filter.
079     * @return the name filtered.
080     */
081    public static String filterName(String name)
082    {
083        // Use lower case
084        // then remove accents
085        // then replace contiguous spaces with one dash
086        // and finally remove non-alphanumeric characters except -
087        String filteredName = Normalizer.normalize(name.toLowerCase(), Normalizer.Form.NFD).replaceAll("[\\p{InCombiningDiacriticalMarks}]", "").trim(); 
088        filteredName = filteredName.replaceAll("œ", "oe").replaceAll("æ", "ae").replaceAll(" +", "-").replaceAll("[^\\w-]", "-").replaceAll("-+", "-");
089
090        Matcher m = __NODE_NAME_PATTERN.matcher(filteredName);
091        if (!m.matches())
092        {
093            throw new IllegalArgumentException(filteredName + " doesn't match the expected regular expression : " + __NODE_NAME_PATTERN.pattern());
094        }
095
096        filteredName = filteredName.substring(m.end(1));
097
098        // Remove characters '-' and '_' at the start and the end of the string
099        return org.apache.commons.lang3.StringUtils.strip(filteredName, "-_");
100    }
101    
102    /**
103     * Get a unique child ametys object name under a traversable ametys object. Default parameters: Incremental mode, may not be suffixed.
104     * The name is filtered and suffixed if necessary. If a suffix is already present, it is removed to be replaced by another one.
105     * @param parent The parent
106     * @param baseName The base name
107     * @return A unique ametys object name (for the given parent)
108     */
109    public static String getUniqueAmetysObjectName(TraversableAmetysObject parent, String baseName)
110    {
111        return getUniqueAmetysObjectName(parent, baseName, NameComputationMode.INCREMENTAL, false);
112    }
113    
114    /**
115     * Get a unique child ametys object name under a traversable ametys object.
116     * The name is filtered and suffixed if necessary. If a suffix is already present, it is removed to be replaced by another one.
117     * @param parent The parent
118     * @param baseName The base name
119     * @param computationMode The computation mode of the name: incremental (old method) or generated key
120     * @param mayBeSuffixed <code>true</code> to consider that the given base name may already contains a suffix, it will be removed if needed
121     * @return A unique ametys object name (for the given parent)
122     */
123    public static String getUniqueAmetysObjectName(TraversableAmetysObject parent, String baseName, NameComputationMode computationMode, boolean mayBeSuffixed)
124    {
125        // Only compute the name if the base name already exists
126        switch (computationMode)
127        {
128            case GENERATED_KEY:
129                return _getUniqueAmetysObjectNameGeneratedKey(parent, baseName, mayBeSuffixed);
130            case USER_FRIENDLY:
131                return _getUniqueAmetysObjectNameUserFriendly(parent, Text.escapeIllegalJcrChars(baseName), mayBeSuffixed);
132            case INCREMENTAL:
133            default:
134                return _getUniqueAmetysObjectNameIncremental(parent, baseName, mayBeSuffixed);
135        }
136    }
137    
138    private static String _getUniqueAmetysObjectNameIncremental(TraversableAmetysObject parent, String baseName, boolean mayBeSuffixed)
139    {
140        String filteredName = filterName(baseName);
141        
142        if (!parent.hasChild(filteredName))
143        {
144            return filteredName;
145        }
146        
147        String realBaseName = _getRealBaseName(baseName, __NAME_INCREMENTAL_PATTERN, mayBeSuffixed);
148        String uniqueName;
149        long index = 2;
150        do
151        {
152            uniqueName = realBaseName + (index++);
153        }
154        while (parent.hasChild(uniqueName));
155        return uniqueName;
156    }
157    
158    private static String _getUniqueAmetysObjectNameGeneratedKey(TraversableAmetysObject parent, String baseName, boolean mayBeSuffixed)
159    {
160        String filteredName = filterName(baseName);
161        
162        if (!parent.hasChild(filteredName))
163        {
164            return filteredName;
165        }
166        
167        String realBaseName = _getRealBaseName(baseName, __NAME_GENERATED_KEY_PATTERN, mayBeSuffixed);
168        String uniqueName;
169        do
170        {
171            uniqueName = realBaseName + StringUtils.generateKey().toLowerCase();
172        }
173        while (parent.hasChild(uniqueName));
174        return uniqueName;
175    }
176    
177    private static String _getUniqueAmetysObjectNameUserFriendly(TraversableAmetysObject parent, String baseName, boolean mayBeSuffixed)
178    {
179        if (!parent.hasChild(baseName))
180        {
181            return baseName;
182        }
183        
184        String realBaseName = baseName;
185        if (mayBeSuffixed)
186        {
187            Matcher m = __NAME_USER_FRIENDLY_PATTERN.matcher(baseName);
188            m.matches();
189            realBaseName = m.group(1);
190        }
191        realBaseName += " (${number})";
192        
193        String uniqueName;
194        Long index = 2L;
195        do
196        {
197            uniqueName = realBaseName.replaceFirst("${number}", index.toString());
198            index++;
199        }
200        while (!parent.hasChild(uniqueName));
201        
202        return uniqueName;
203    }
204    
205    private static String _getRealBaseName(String baseName, Pattern pattern, boolean mayBeSuffixed)
206    {
207        if (!mayBeSuffixed)
208        {
209            return filterName(baseName) + "-";
210        }
211
212        // "-" is not in the first group, because the second group is optional so it may be not present
213        // Add it there to avoid string computation
214        Matcher m = pattern.matcher(baseName);
215        m.matches();
216        return filterName(m.group(1)) + "-";
217    }
218}