001/* 002 * This library is part of OpenCms - 003 * the Open Source Content Management System 004 * 005 * Copyright (c) Alkacon Software GmbH & Co. KG (https://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: https://www.alkacon.com 019 * 020 * For further information about OpenCms, please see the 021 * project website: https://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.i18n; 029 030import org.opencms.ade.configuration.CmsADEManager; 031import org.opencms.file.CmsObject; 032import org.opencms.file.CmsProperty; 033import org.opencms.file.CmsPropertyDefinition; 034import org.opencms.file.CmsResource; 035import org.opencms.file.CmsResourceFilter; 036import org.opencms.lock.CmsLock; 037import org.opencms.lock.CmsLockActionRecord; 038import org.opencms.lock.CmsLockActionRecord.LockChange; 039import org.opencms.lock.CmsLockUtil; 040import org.opencms.main.CmsException; 041import org.opencms.main.CmsLog; 042import org.opencms.main.OpenCms; 043import org.opencms.relations.CmsRelation; 044import org.opencms.relations.CmsRelationFilter; 045import org.opencms.relations.CmsRelationType; 046import org.opencms.security.CmsPermissionSet; 047import org.opencms.security.CmsSecurityException; 048import org.opencms.site.CmsSite; 049import org.opencms.util.CmsFileUtil; 050import org.opencms.util.CmsStringUtil; 051import org.opencms.util.CmsUUID; 052 053import java.util.ArrayList; 054import java.util.Iterator; 055import java.util.List; 056import java.util.Locale; 057import java.util.Set; 058 059import org.apache.commons.logging.Log; 060 061import com.google.common.collect.Lists; 062import com.google.common.collect.Sets; 063 064/** 065 * Helper class for manipulating locale groups.<p> 066 * 067 * A locale group is a construct used to group pages which are translations of each other. 068 * * 069 * A locale group consists of a set of resources connected by relations in the following way:<p> 070 * <ul> 071 * <li> There is a primary resource and a set of secondary resources. 072 * <li> Each secondary resource has a relation to the primary resource of type LOCALE_VARIANT. 073 * <li> Ideally, each resource has a different locale. 074 * </ul> 075 * 076 * The point of the primary resource is to act as a 'master' resource which translators then use to translate to different locales. 077 */ 078public class CmsLocaleGroupService { 079 080 /** 081 * Enum representing whether two resources can be linked together in a locale group.<p> 082 */ 083 public enum Status { 084 /** Resource already linked. */ 085 alreadyLinked, 086 087 /** Resource linkable to locale group.*/ 088 linkable, 089 090 /** Resource to link has a locale which is marked as 'do not translate' on the locale group. */ 091 notranslation, 092 093 /** Other reason that resource can't be linked to locale group. */ 094 other 095 } 096 097 /** The logger instance for this class. */ 098 private static final Log LOG = CmsLog.getLog(CmsLocaleGroupService.class); 099 100 /** CMS context to use for VFS operations. */ 101 private CmsObject m_cms; 102 103 /** 104 * Creates a new instance.<p> 105 * 106 * @param cms the CMS context to use 107 */ 108 public CmsLocaleGroupService(CmsObject cms) { 109 110 m_cms = cms; 111 } 112 113 /** 114 * Helper method for getting the possible locales for a resource.<p> 115 * 116 * @param cms the CMS context 117 * @param currentResource the resource 118 * 119 * @return the possible locales for a resource 120 */ 121 public static List<Locale> getPossibleLocales(CmsObject cms, CmsResource currentResource) { 122 123 CmsSite site = OpenCms.getSiteManager().getSiteForRootPath(currentResource.getRootPath()); 124 List<Locale> secondaryLocales = Lists.newArrayList(); 125 Locale mainLocale = null; 126 if (site != null) { 127 List<Locale> siteLocales = site.getSecondaryTranslationLocales(); 128 mainLocale = site.getMainTranslationLocale(null); 129 if ((siteLocales == null) || siteLocales.isEmpty()) { 130 siteLocales = OpenCms.getLocaleManager().getAvailableLocales(); 131 if (mainLocale == null) { 132 mainLocale = siteLocales.get(0); 133 } 134 } 135 secondaryLocales.addAll(siteLocales); 136 } 137 138 try { 139 CmsProperty secondaryLocaleProp = cms.readPropertyObject( 140 currentResource, 141 CmsPropertyDefinition.PROPERTY_SECONDARY_LOCALES, 142 true); 143 String propValue = secondaryLocaleProp.getValue(); 144 if (!CmsStringUtil.isEmptyOrWhitespaceOnly(propValue)) { 145 List<Locale> restrictionLocales = Lists.newArrayList(); 146 String[] tokens = propValue.trim().split(" *, *"); //$NON-NLS-1$ 147 for (String token : tokens) { 148 OpenCms.getLocaleManager(); 149 Locale localeForToken = CmsLocaleManager.getLocale(token); 150 restrictionLocales.add(localeForToken); 151 } 152 if (!restrictionLocales.isEmpty()) { 153 secondaryLocales.retainAll(restrictionLocales); 154 } 155 } 156 } catch (CmsException e) { 157 LOG.error(e.getLocalizedMessage(), e); 158 } 159 List<Locale> result = new ArrayList<Locale>(); 160 result.add(mainLocale); 161 for (Locale secondaryLocale : secondaryLocales) { 162 if (!result.contains(secondaryLocale)) { 163 result.add(secondaryLocale); 164 } 165 } 166 return result; 167 } 168 169 /** 170 * Adds a resource to a locale group.<p> 171 * 172 * Note: This is a low level method that is hard to use correctly. Please use attachLocaleGroupIndirect if at all possible. 173 * 174 * @param secondaryPage the page to add 175 * @param primaryPage the primary resource of the locale group which the resource should be added to 176 * @throws CmsException if something goes wrong 177 */ 178 public void attachLocaleGroup(CmsResource secondaryPage, CmsResource primaryPage) throws CmsException { 179 180 if (secondaryPage.getStructureId().equals(primaryPage.getStructureId())) { 181 throw new IllegalArgumentException( 182 "A page can not be linked with itself as a locale variant: " + secondaryPage.getRootPath()); 183 } 184 CmsLocaleGroup group = readLocaleGroup(secondaryPage); 185 if (group.isRealGroup()) { 186 throw new IllegalArgumentException( 187 "The page " + secondaryPage.getRootPath() + " is already part of a group. "); 188 } 189 190 // TODO: Check for redundant locales 191 192 CmsLocaleGroup targetGroup = readLocaleGroup(primaryPage); 193 CmsLockActionRecord record = CmsLockUtil.ensureLock(m_cms, secondaryPage); 194 try { 195 m_cms.deleteRelationsFromResource( 196 secondaryPage, 197 CmsRelationFilter.TARGETS.filterType(CmsRelationType.LOCALE_VARIANT)); 198 m_cms.addRelationToResource( 199 secondaryPage, 200 targetGroup.getPrimaryResource(), 201 CmsRelationType.LOCALE_VARIANT.getName()); 202 } finally { 203 if (record.getChange() == LockChange.locked) { 204 m_cms.unlockResource(secondaryPage); 205 } 206 } 207 } 208 209 /** 210 * Smarter method to connect a resource to a locale group.<p> 211 * 212 * Exactly one of the resources given as an argument must represent a locale group, while the other should 213 * be the locale that you wish to attach to the locale group.<p> 214 * 215 * @param first a resource 216 * @param second a resource 217 * @throws CmsException if something goes wrong 218 */ 219 public void attachLocaleGroupIndirect(CmsResource first, CmsResource second) throws CmsException { 220 221 CmsResource firstResourceCorrected = getDefaultFileOrSelf(first); 222 CmsResource secondResourceCorrected = getDefaultFileOrSelf(second); 223 if ((firstResourceCorrected == null) || (secondResourceCorrected == null)) { 224 throw new IllegalArgumentException("no default file"); 225 } 226 227 CmsLocaleGroup group1 = readLocaleGroup(firstResourceCorrected); 228 CmsLocaleGroup group2 = readLocaleGroup(secondResourceCorrected); 229 int numberOfRealGroups = (group1.isRealGroupOrPotentialGroupHead() ? 1 : 0) 230 + (group2.isRealGroupOrPotentialGroupHead() ? 1 : 0); 231 if (numberOfRealGroups != 1) { 232 throw new IllegalArgumentException("more than one real groups"); 233 } 234 CmsResource main = null; 235 CmsResource secondary = null; 236 if (group1.isRealGroupOrPotentialGroupHead()) { 237 main = group1.getPrimaryResource(); 238 secondary = group2.getPrimaryResource(); 239 } else if (group2.isRealGroupOrPotentialGroupHead()) { 240 main = group2.getPrimaryResource(); 241 secondary = group1.getPrimaryResource(); 242 } 243 attachLocaleGroup(secondary, main); 244 } 245 246 /** 247 * Checks if the two resources are linkable as locale variants and returns an appropriate status<p> 248 * 249 * This is the case if exactly one of the resources represents a locale group, the locale of the other resource 250 * is not already present in the locale group, and if some other permission / validity checks are passed. 251 * 252 * @param firstResource a resource 253 * @param secondResource a resource 254 * 255 * @return the result of the linkability check 256 */ 257 public Status checkLinkable(CmsResource firstResource, CmsResource secondResource) { 258 259 String debugPrefix = "checkLinkable [" + Thread.currentThread().getName() + "]: "; 260 LOG.debug( 261 debugPrefix 262 + (firstResource != null ? firstResource.getRootPath() : null) 263 + " -- " 264 + (secondResource != null ? secondResource.getRootPath() : null)); 265 try { 266 CmsResource firstResourceCorrected = getDefaultFileOrSelf(firstResource); 267 CmsResource secondResourceCorrected = getDefaultFileOrSelf(secondResource); 268 if ((firstResourceCorrected == null) || (secondResourceCorrected == null)) { 269 LOG.debug(debugPrefix + " rejected - no resource"); 270 return Status.other; 271 } 272 Locale locale1 = OpenCms.getLocaleManager().getDefaultLocale(m_cms, firstResourceCorrected); 273 Locale locale2 = OpenCms.getLocaleManager().getDefaultLocale(m_cms, secondResourceCorrected); 274 if (locale1.equals(locale2)) { 275 LOG.debug(debugPrefix + " rejected - same locale " + locale1); 276 return Status.other; 277 } 278 279 Locale mainLocale1 = getMainLocale(firstResourceCorrected.getRootPath()); 280 Locale mainLocale2 = getMainLocale(secondResourceCorrected.getRootPath()); 281 if ((mainLocale1 == null) || !(mainLocale1.equals(mainLocale2))) { 282 LOG.debug(debugPrefix + " rejected - incompatible main locale " + mainLocale1 + "/" + mainLocale2); 283 return Status.other; 284 } 285 286 CmsLocaleGroup group1 = readLocaleGroup(firstResourceCorrected); 287 Set<Locale> locales1 = group1.getLocales(); 288 CmsLocaleGroup group2 = readLocaleGroup(secondResourceCorrected); 289 Set<Locale> locales2 = group2.getLocales(); 290 if (!(Sets.intersection(locales1, locales2).isEmpty())) { 291 LOG.debug(debugPrefix + " rejected - already linked (case 1)"); 292 return Status.alreadyLinked; 293 } 294 295 if (group1.isMarkedNoTranslation(group2.getLocales()) 296 || group2.isMarkedNoTranslation(group1.getLocales())) { 297 LOG.debug(debugPrefix + " rejected - marked 'no translation'"); 298 return Status.notranslation; 299 } 300 301 if (group1.isRealGroupOrPotentialGroupHead() == group2.isRealGroupOrPotentialGroupHead()) { 302 LOG.debug(debugPrefix + " rejected - incompatible locale group states"); 303 return Status.other; 304 } 305 306 CmsResource permCheckResource = null; 307 if (group1.isRealGroupOrPotentialGroupHead()) { 308 permCheckResource = group2.getPrimaryResource(); 309 } else { 310 permCheckResource = group1.getPrimaryResource(); 311 } 312 if (!m_cms.hasPermissions( 313 permCheckResource, 314 CmsPermissionSet.ACCESS_WRITE, 315 false, 316 CmsResourceFilter.IGNORE_EXPIRATION)) { 317 LOG.debug(debugPrefix + " no write permissions: " + permCheckResource.getRootPath()); 318 return Status.other; 319 } 320 321 if (!checkLock(permCheckResource)) { 322 LOG.debug(debugPrefix + " lock state: " + permCheckResource.getRootPath()); 323 return Status.other; 324 } 325 326 if (group2.getPrimaryResource().getStructureId().equals(group1.getPrimaryResource().getStructureId())) { 327 LOG.debug(debugPrefix + " rejected - already linked (case 2)"); 328 return Status.alreadyLinked; 329 } 330 } catch (Exception e) { 331 LOG.error(debugPrefix + e.getLocalizedMessage(), e); 332 LOG.debug(debugPrefix + " rejected - exception (see previous)"); 333 return Status.other; 334 } 335 LOG.debug(debugPrefix + " OK"); 336 return Status.linkable; 337 } 338 339 /** 340 * Removes a locale group relation between two resources.<p> 341 * 342 * @param firstPage the first resource 343 * @param secondPage the second resource 344 * @throws CmsException if something goes wrong 345 */ 346 public void detachLocaleGroup(CmsResource firstPage, CmsResource secondPage) throws CmsException { 347 348 CmsRelationFilter typeFilter = CmsRelationFilter.ALL.filterType(CmsRelationType.LOCALE_VARIANT); 349 firstPage = getDefaultFileOrSelf(firstPage); 350 secondPage = getDefaultFileOrSelf(secondPage); 351 if ((firstPage == null) || (secondPage == null)) { 352 return; 353 } 354 355 List<CmsRelation> relations = m_cms.readRelations(typeFilter.filterStructureId(secondPage.getStructureId())); 356 CmsUUID firstId = firstPage.getStructureId(); 357 CmsUUID secondId = secondPage.getStructureId(); 358 for (CmsRelation relation : relations) { 359 CmsUUID sourceId = relation.getSourceId(); 360 CmsUUID targetId = relation.getTargetId(); 361 CmsResource resourceToModify = null; 362 if (sourceId.equals(firstId) && targetId.equals(secondId)) { 363 resourceToModify = firstPage; 364 } else if (sourceId.equals(secondId) && targetId.equals(firstId)) { 365 resourceToModify = secondPage; 366 } 367 if (resourceToModify != null) { 368 CmsLockActionRecord record = CmsLockUtil.ensureLock(m_cms, resourceToModify); 369 try { 370 m_cms.deleteRelationsFromResource( 371 resourceToModify, 372 CmsRelationFilter.TARGETS.filterType(CmsRelationType.LOCALE_VARIANT)); 373 } finally { 374 if (record.getChange() == LockChange.locked) { 375 m_cms.unlockResource(resourceToModify); 376 } 377 } 378 break; 379 } 380 } 381 } 382 383 /** 384 * Tries to find the 'best' localized subsitemap parent folder for a resource.<p> 385 * 386 * This is used when we use locale group dialogs outside the sitemap editor, so we 387 * don't have a clearly defined 'root resource' - this method is used to find a replacement 388 * for the root resource which we would have in the sitemap editor. 389 * 390 * @param resource the resource for which to find the localization root 391 * @return the localization root 392 * 393 * @throws CmsException if something goes wrong 394 */ 395 public CmsResource findLocalizationRoot(CmsResource resource) throws CmsException { 396 397 String rootPath = resource.getRootPath(); 398 LOG.debug("Trying to find localization root for " + rootPath); 399 if (resource.isFile()) { 400 rootPath = CmsResource.getParentFolder(rootPath); 401 } 402 CmsObject cms = OpenCms.initCmsObject(m_cms); 403 cms.getRequestContext().setSiteRoot(""); 404 String currentPath = rootPath; 405 CmsSite site = OpenCms.getSiteManager().getSiteForRootPath(rootPath); 406 if (site == null) { 407 return null; 408 } 409 String siteroot = site.getSiteRoot(); 410 List<String> ancestors = Lists.newArrayList(); 411 while ((currentPath != null) && CmsStringUtil.isPrefixPath(siteroot, currentPath)) { 412 ancestors.add(currentPath); 413 currentPath = CmsResource.getParentFolder(currentPath); 414 } 415 Iterator<String> iter = ancestors.iterator(); 416 while (iter.hasNext()) { 417 String path = iter.next(); 418 if (CmsFileUtil.removeTrailingSeparator(path).equals(CmsFileUtil.removeTrailingSeparator(siteroot))) { 419 LOG.debug("keeping path because it is the site root: " + path); 420 } else if (cms.existsResource( 421 CmsStringUtil.joinPaths(path, CmsADEManager.CONFIG_SUFFIX), 422 CmsResourceFilter.IGNORE_EXPIRATION)) { 423 LOG.debug("keeping path because it is a sub-sitemap: " + path); 424 } else { 425 LOG.debug("removing path " + path); 426 iter.remove(); 427 } 428 } 429 for (String ancestor : ancestors) { 430 try { 431 CmsResource ancestorRes = cms.readResource(ancestor, CmsResourceFilter.IGNORE_EXPIRATION); 432 CmsLocaleGroup group = readLocaleGroup(ancestorRes); 433 if (group.isRealGroup()) { 434 return ancestorRes; 435 } 436 } catch (CmsException e) { 437 LOG.error(e.getLocalizedMessage(), e); 438 } 439 } 440 // there is at least one ancestor: the site root 441 String result = ancestors.get(0); 442 LOG.debug("result = " + result); 443 return cms.readResource(result, CmsResourceFilter.IGNORE_EXPIRATION); 444 445 } 446 447 /** 448 * Gets the main translation locale configured for the given root path.<p> 449 * 450 * @param rootPath a root path 451 * 452 * @return the main translation locale configured for the given path, or null if none was found 453 */ 454 public Locale getMainLocale(String rootPath) { 455 456 CmsSite site = OpenCms.getSiteManager().getSiteForRootPath(rootPath); 457 if (site == null) { 458 return null; 459 } 460 return site.getMainTranslationLocale(null); 461 } 462 463 /** 464 * Reads the locale group of a default file.<p> 465 * 466 * @param resource a resource which might be a folder or a file 467 * @return the locale group corresponding to the default file 468 * 469 * @throws CmsException if something goes wrong 470 */ 471 public CmsLocaleGroup readDefaultFileLocaleGroup(CmsResource resource) throws CmsException { 472 473 if (resource.isFolder()) { 474 CmsResource defaultFile = m_cms.readDefaultFile(resource, CmsResourceFilter.IGNORE_EXPIRATION); 475 if (defaultFile != null) { 476 return readLocaleGroup(defaultFile); 477 } else { 478 LOG.warn("default file not found, reading locale group of folder."); 479 } 480 } 481 return readLocaleGroup(resource); 482 483 } 484 485 /** 486 * Reads a locale group from the VFS.<p> 487 * 488 * @param resource the resource for which to read the locale group 489 * 490 * @return the locale group for the resource 491 * @throws CmsException if something goes wrong 492 */ 493 public CmsLocaleGroup readLocaleGroup(CmsResource resource) throws CmsException { 494 495 if (resource.isFolder()) { 496 CmsResource defaultFile = m_cms.readDefaultFile(resource, CmsResourceFilter.IGNORE_EXPIRATION); 497 if (defaultFile != null) { 498 resource = defaultFile; 499 } 500 } 501 502 List<CmsRelation> relations = m_cms.readRelations( 503 CmsRelationFilter.ALL.filterType(CmsRelationType.LOCALE_VARIANT).filterStructureId( 504 resource.getStructureId())); 505 List<CmsRelation> out = Lists.newArrayList(); 506 List<CmsRelation> in = Lists.newArrayList(); 507 for (CmsRelation rel : relations) { 508 if (rel.getSourceId().equals(resource.getStructureId())) { 509 out.add(rel); 510 } else { 511 in.add(rel); 512 } 513 } 514 CmsResource primaryResource = null; 515 List<CmsResource> secondaryResources = Lists.newArrayList(); 516 if ((out.size() == 0) && (in.size() == 0)) { 517 primaryResource = resource; 518 } else if ((out.size() == 0) && (in.size() > 0)) { 519 primaryResource = resource; 520 // resource is the primary variant 521 for (CmsRelation relation : in) { 522 CmsResource source = relation.getSource(m_cms, CmsResourceFilter.ALL); 523 secondaryResources.add(source); 524 } 525 } else if ((out.size() == 1) && (in.size() == 0)) { 526 527 CmsResource target = out.get(0).getTarget(m_cms, CmsResourceFilter.ALL); 528 primaryResource = target; 529 CmsRelationFilter filter = CmsRelationFilter.TARGETS.filterType( 530 CmsRelationType.LOCALE_VARIANT).filterStructureId(target.getStructureId()); 531 List<CmsRelation> relationsToTarget = m_cms.readRelations(filter); 532 for (CmsRelation targetRelation : relationsToTarget) { 533 CmsResource secondaryResource = targetRelation.getSource(m_cms, CmsResourceFilter.ALL); 534 secondaryResources.add(secondaryResource); 535 } 536 } else { 537 throw new IllegalStateException( 538 "illegal locale variant relations for resource with id=" 539 + resource.getStructureId() 540 + ", path=" 541 + resource.getRootPath()); 542 } 543 return new CmsLocaleGroup(m_cms, primaryResource, secondaryResources); 544 } 545 546 /** 547 * Helper method for reading the default file of a folder.<p> 548 * 549 * If the resource given already is a file, it will be returned, otherwise 550 * the default file (or null, if none exists) of the folder will be returned. 551 * 552 * @param res the resource whose default file to read 553 * @return the default file 554 */ 555 protected CmsResource getDefaultFileOrSelf(CmsResource res) { 556 557 CmsResource defaultfile = null; 558 if (res.isFolder()) { 559 try { 560 defaultfile = m_cms.readDefaultFile("" + res.getStructureId()); 561 } catch (CmsSecurityException e) { 562 LOG.error(e.getLocalizedMessage(), e); 563 return null; 564 } catch (CmsException e) { 565 LOG.error(e.getLocalizedMessage(), e); 566 return null; 567 } 568 return defaultfile; 569 } 570 return res; 571 572 } 573 574 /** 575 * Checks that the resource is not locked by another user.<p> 576 * 577 * @param resource the resource 578 * @return true if the resource is not locked by another user 579 * 580 * @throws CmsException if something goes wrong 581 */ 582 private boolean checkLock(CmsResource resource) throws CmsException { 583 584 CmsLock lock = m_cms.getLock(resource); 585 return lock.isUnlocked() || lock.getUserId().equals(m_cms.getRequestContext().getCurrentUser().getId()); 586 } 587 588}