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}