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