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}