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}