package com.psddev.cms.db; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.regex.Matcher; import java.util.regex.Pattern; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.util.concurrent.UncheckedExecutionException; import com.psddev.cms.tool.CmsTool; import com.psddev.dari.db.Application; import com.psddev.dari.db.Modification; import com.psddev.dari.db.Predicate; import com.psddev.dari.db.PredicateParser; import com.psddev.dari.db.Query; import com.psddev.dari.db.Record; import com.psddev.dari.db.State; import com.psddev.dari.util.ObjectUtils; import com.psddev.dari.util.StringUtils; @Record.BootstrapTypeMappable(groups = Directory.Item.class, uniqueKey = "path") public class Directory extends Record { public static final String FIELD_PREFIX = "cms.directory."; public static final String PATHS_MODE_FIELD = FIELD_PREFIX + "pathsMode"; public static final String OBJECT_NAME_FIELD = FIELD_PREFIX + "objectName"; public static final String PATHS_FIELD = FIELD_PREFIX + "paths"; public static final String PATH_TYPES_FIELD = FIELD_PREFIX + "pathTypes"; private static final Pattern EXTERNAL_URL_PATTERN = Pattern.compile("(?i)/(https?:)/(.*)"); @Indexed(unique = true) @Required private String path; /** * Cleans up the given {@code path} so that it always looks like * {@code /path/to/stuff/}. */ public static String normalizePath(String path) { if (path == null || path.length() == 0) { return null; } path = "/" + path + "/"; path = StringUtils.replaceAll(path, "/(?:/+|\\./)", "/"); return path; } /** * Extracts a valid external URL from the given {@code path} if possible. * * @param path If {@code null}, returns {@code null}. * @return May be {@code null} if the path doesn't look like an external * url. */ public static String extractExternalUrl(String path) { if (path != null) { Matcher externalUrlMatcher = EXTERNAL_URL_PATTERN.matcher(path); if (externalUrlMatcher.matches()) { return externalUrlMatcher.group(1) + "//" + externalUrlMatcher.group(2); } } return null; } /** Returns the path. */ public String getPath() { return path; } /** Sets the path. */ public void setPath(String path) { this.path = normalizePath(path); } /** Returns the raw path that is stored within other objects. */ public String getRawPath() { return getId() + "/"; } /** * Returns a predicate that filters out any items that's not associated * with this directory in the given {@code site}. */ public Predicate itemsPredicate(Site site) { String path = getRawPath(); if (site != null) { path = site.getRawPath() + path; } return PredicateParser.Static.parse(PATHS_FIELD + " ^= ?", path); } /** @deprecated Use {@link Static#findObject} instead. */ @Deprecated public static Object findObjectByPath(Site site, String path) { return Static.findObject(site, path); } /** @deprecated Use {@link Static#findObject} instead. */ @Deprecated public static Object findObjectByPath(String path) { return Static.findObject(null, path); } /** @deprecated Use {@link Static#hasPathPredicate} instead. */ @Deprecated public static Predicate hasPathPredicate() { return Static.hasPathPredicate(); } /** @deprecated Use {@link #itemsPredicate(Site)} instead. */ @Deprecated public Predicate itemsPredicate() { return itemsPredicate(null); } @FieldInternalNamePrefix("cms.directory.") public static class Data extends Modification<Object> { private static final Pattern RAW_PATH_PATTERN = Pattern.compile("^(?:([^/]+):)?([^/]+)/([^/]+)$"); @ToolUi.Hidden private Set<String> automaticRawPaths; public PathsMode getPathsMode() { return as(Directory.ObjectModification.class).getPathsMode(); } public void setPathsMode(PathsMode pathsMode) { as(Directory.ObjectModification.class).setPathsMode(pathsMode); } public List<String> getRawPaths() { return as(Directory.ObjectModification.class).getRawPaths(); } public void setRawPaths(List<String> rawPaths) { as(Directory.ObjectModification.class).setRawPaths(rawPaths); } public Set<String> getAutomaticRawPaths() { if (automaticRawPaths == null) { automaticRawPaths = new LinkedHashSet<String>(); } return automaticRawPaths; } public void setAutomaticRawPaths(Set<String> automaticRawPaths) { this.automaticRawPaths = automaticRawPaths; } /** * Adds the given {@code path} in the given {@code site} with the * given {@code type} to this object. * * @param site May be {@code null}. * @param path If blank, does nothing. * @param type May be {@code null}. */ public void addPath(Site site, String path, PathType type) { as(Directory.ObjectModification.class).addSitePath(site, path, type); } /** * Clears all paths associated with this object. */ public void clearPaths() { Directory.ObjectModification dirData = as(Directory.ObjectModification.class); dirData.getRawPaths().clear(); dirData.getPathTypes().clear(); } private Set<Path> convertRawPaths(Collection<String> rawPaths) { if (ObjectUtils.isBlank(rawPaths)) { return Collections.emptySet(); } Directory.ObjectModification dirData = as(Directory.ObjectModification.class); Map<String, PathType> pathTypes = dirData.getPathTypes(); Set<Path> paths = new LinkedHashSet<Path>(); for (String rawPath : rawPaths) { Matcher rawPathMatcher = RAW_PATH_PATTERN.matcher(rawPath); if (rawPathMatcher.matches()) { UUID directoryId = ObjectUtils.to(UUID.class, rawPathMatcher.group(2)); Directory directory = Query .from(Directory.class) .where("_id = ?", directoryId) .first(); if (directory != null) { String path = directory.getPath() + rawPathMatcher.group(3); Site site = Query .from(Site.class) .where("_id = ?", ObjectUtils.to(UUID.class, rawPathMatcher.group(1))) .first(); if (path.endsWith("/index")) { path = path.substring(0, path.length() - 5); } paths.add(new Path(site, path, pathTypes.get(rawPath))); } } } return paths; } /** * Returns a set of all paths associated with this object. * * @return Never {@code null}. */ public Set<Path> getPaths() { return convertRawPaths(as(Directory.ObjectModification.class).getRawPaths()); } public Set<Path> getAutomaticPaths() { return convertRawPaths(getAutomaticRawPaths()); } public Set<Path> getManualPaths() { List<String> rawPaths = as(Directory.ObjectModification.class).getRawPaths(); rawPaths.removeAll(getAutomaticRawPaths()); return convertRawPaths(rawPaths); } } /** How the paths should be constructed. */ public enum PathsMode { AUTOMATIC("Automatic"), MANUAL("Manual"); private final String displayName; private PathsMode(String displayName) { this.displayName = displayName; } // --- Object support --- @Override public String toString() { return displayName; } } /** How the path should be interpreted. */ public enum PathType { PERMALINK("Permalink"), ALIAS("Alias"), REDIRECT("Redirect (Permanent)"), REDIRECT_TEMPORARY("Redirect (Temporary)"); private final String displayName; private PathType(String displayName) { this.displayName = displayName; } // --- Object support --- @Override public String toString() { return displayName; } } public static class Path { private final Site site; private final String path; private final PathType type; public Path(Site site, String path, PathType type) { this.site = site; this.path = path != null ? path.trim() : null; this.type = type != null ? type : PathType.PERMALINK; } public Site getSite() { return site; } public String getPath() { return path != null ? path.trim() : null; } public PathType getType() { return type; } // --- Object support --- @Override public boolean equals(Object other) { if (this == other) { return true; } else if (other instanceof Path) { Path otherPath = (Path) other; return ObjectUtils.equals(getSite(), otherPath.getSite()) && ObjectUtils.equals(getPath(), otherPath.getPath()); } else { return false; } } @Override public int hashCode() { return ObjectUtils.hashCode(getSite(), getPath()); } @Override public String toString() { StringBuilder s = new StringBuilder(); Site site = getSite(); s.append("{"); if (site != null) { s.append("site="); s.append(site.getLabel()); s.append(", "); } s.append("path="); s.append(getPath()); s.append(", type="); s.append(getType()); s.append("}"); return s.toString(); } } public interface Item { /** Creates the permalink appropriate for the given {@code site}. */ public String createPermalink(Site site); } /** Modification that adds directory information. */ public static final class ObjectModification extends Modification<Object> { @InternalName(PATHS_MODE_FIELD) @ToolUi.Hidden private PathsMode pathsMode; @InternalName(OBJECT_NAME_FIELD) @ToolUi.Hidden private String objectName; @Indexed(unique = true) @InternalName(PATHS_FIELD) @ToolUi.Hidden private List<String> paths; @InternalName(PATH_TYPES_FIELD) @ToolUi.Hidden private Map<String, PathType> pathTypes; /** Returns the paths mode. */ public PathsMode getPathsMode() { return pathsMode; } /** Sets the paths mode. */ public void setPathsMode(PathsMode pathsMode) { this.pathsMode = pathsMode; } /** Returns the object name. */ public String getObjectName() { return objectName; } /** Sets the object name. */ public void setObjectName(String objectName) { this.objectName = objectName; } /** Returns the raw paths. */ public List<String> getRawPaths() { if (paths == null) { paths = new ArrayList<String>(); } return paths; } /** Sets the raw paths. */ public void setRawPaths(List<String> paths) { this.paths = paths; } /** Returns the path types. */ public Map<String, PathType> getPathTypes() { if (pathTypes == null) { pathTypes = new LinkedHashMap<String, PathType>(); } return pathTypes; } /** Sets the path types. */ public void setPathTypes(Map<String, PathType> pathTypes) { this.pathTypes = pathTypes; } /** * Makes a raw path, like {@code siteId:directoryId/item}, * using the given {@code site} and {@code path}. */ private String makeRawPath(Site site, String path) { Matcher pathMatcher = StringUtils.getMatcher(normalizePath(path), "^(/.*?)([^/]*)/?$"); pathMatcher.find(); path = pathMatcher.group(1); Directory dir = Query .from(Directory.class) .where("path = ?", path) .master() .noCache() .first(); if (dir == null) { dir = new Directory(); dir.setPath(path); dir.saveImmediately(); } String rawPath = dir.getRawPath() + pathMatcher.group(2); if (site != null) { rawPath = site.getRawPath() + rawPath; } return rawPath; } /** * Returns all paths in the given {@code site} associated with * this object. */ public List<Path> getSitePaths(Site site) { List<String> rawPaths = getRawPaths(); if (ObjectUtils.isBlank(rawPaths)) { return Collections.emptyList(); } Map<String, PathType> pathTypes = getPathTypes(); List<Path> paths = new ArrayList<Path>(); for (String rawPath : rawPaths) { Matcher rawPathMatcher = StringUtils.getMatcher(rawPath, "^(?:([^/]+):)?([^/]+)/([^/]+)$"); if (rawPathMatcher.matches()) { UUID siteId = ObjectUtils.to(UUID.class, rawPathMatcher.group(1)); if ((siteId == null && site == null) || (site != null && site.getId().equals(siteId))) { UUID directoryId = ObjectUtils.to(UUID.class, rawPathMatcher.group(2)); if (directoryId != null) { String directoryPath = null; try { directoryPath = DIRECTORY_PATHS.getUnchecked(directoryId); } catch (UncheckedExecutionException error) { Directory directory = Query .from(Directory.class) .where("_id = ?", directoryId) .first(); if (directory != null) { directoryPath = directory.getPath(); } } if (directoryPath != null) { String path = directoryPath + rawPathMatcher.group(3); if (path.endsWith("/index")) { path = path.substring(0, path.length() - 5); } paths.add(new Path(site, path, pathTypes.get(rawPath))); } } } } } return paths; } private static final RuntimeException DIRECTORY_NOT_FOUND = new RuntimeException(); private static final LoadingCache<UUID, String> DIRECTORY_PATHS = CacheBuilder .newBuilder() .maximumSize(1000) .build(new CacheLoader<UUID, String>() { @Override public String load(UUID directoryId) { Directory directory = Query .from(Directory.class) .where("_id = ?", directoryId) .first(); if (directory != null) { return directory.getPath(); } else { throw DIRECTORY_NOT_FOUND; } } }); /** * Adds the given {@code path} in the given {@code site} with * the given {@code type} to this object. */ public void addSitePath(Site site, String path, PathType type) { if (ObjectUtils.isBlank(path)) { return; } if (path.endsWith("/")) { path += "index"; } List<String> rawPaths = getRawPaths(); String rawPath = makeRawPath(site, path); if (!rawPaths.contains(rawPath)) { rawPaths.add(rawPath); } getPathTypes().put(rawPath, type); } /** * Clears all paths in the given {@code site} associated with * this object. */ public void clearSitePaths(Site site) { String sitePrefix = site != null ? site.getRawPath() : null; Map<String, PathType> types = getPathTypes(); for (Iterator<String> i = getRawPaths().iterator(); i.hasNext();) { String rawPath = i.next(); if ((sitePrefix == null && !rawPath.contains(":")) || (sitePrefix != null && rawPath.startsWith(sitePrefix))) { i.remove(); types.remove(rawPath); } } } /** * Removes the given {@code path} in the given {@code site} from * this object. */ public void removeSitePath(Site site, String path) { String rawPath = makeRawPath(site, path); getRawPaths().remove(rawPath); getPathTypes().remove(rawPath); } /** * Returns the type of the given {@code path} in the given * {@code site} associated with this object. */ public PathType getSitePathType(Site site, String path) { PathType type = getPathTypes().get(makeRawPath(site, path)); return type != null ? type : PathType.PERMALINK; } /** * Puts the given {@code type} into the given {@code path} in the * given {@code site} stored in this object. */ public void putSitePathType(Site site, String path, PathType type) { Map<String, PathType> types = getPathTypes(); String rawPath = makeRawPath(site, path); if (type == null || type == PathType.PERMALINK) { types.remove(rawPath); } else { types.put(rawPath, type); } } /** * Returns the permalink in the given {@code site} for * this object. */ public String getSitePermalink(Site site) { for (Path path : getSitePaths(site)) { if (path.getType() == PathType.PERMALINK) { return site != null ? site.getPrimaryUrl() + path.getPath() : path.getPath(); } } return null; } /** * Returns the permalink (path only) in the given {@code site} for * this object. */ public String getSitePermalinkPath(Site site) { for (Path path : getSitePaths(site)) { if (path.getType() == PathType.PERMALINK) { return path.getPath(); } } return null; } /** Returns the site that owns this object. */ private Site getOwner() { return as(Site.ObjectModification.class).getOwner(); } /** * Returns all paths in the given {@linkplain * Site.ObjectModification#getOwner owner site} associated with * this object. */ public List<Path> getPaths() { return getSitePaths(getOwner()); } /** * Adds the given {@code path} in the {@linkplain * Site.ObjectModification#getOwner owner site} with the given * {@code type} to this object. */ public void addPath(String path, PathType type) { addSitePath(getOwner(), path, type); } /** * Clears all paths in the {@linkplain * Site.ObjectModification#getOwner owner site} associated with * this object. */ public void clearPaths() { clearSitePaths(getOwner()); } /** * Removes the given {@code path} in the {@linkplain * Site.ObjectModification#getOwner owner site} from this object. */ public void removePath(String path) { removeSitePath(getOwner(), path); } /** * Returns the type of the given {@code path} in the {@linkplain * Site.ObjectModification#getOwner owner site} associated with * this object. */ public PathType getPathType(String path) { return getSitePathType(getOwner(), path); } /** * Puts the given {@code type} into the given {@code path} in the * {@linkplain Site.ObjectModification#getOwner owner site} stored * in this object. */ public void putPathType(String path, PathType type) { putSitePathType(getOwner(), path, type); } /** Returns the best permalink associated with this object. */ public String getPermalink() { String permalink = getSitePermalink(getOwner()); if (ObjectUtils.isBlank(permalink)) { permalink = getSitePermalink(null); } return permalink; } /** * Returns the full permalink associated with this object. * * @throws IllegalStateException If {@link CmsTool#getDefaultSiteUrl} * returns blank. */ public String getFullPermalink() { String siteUrl = Application.Static.getInstance(CmsTool.class).getDefaultSiteUrl(); if (ObjectUtils.isBlank(siteUrl)) { throw new IllegalStateException("Default site URL not configured!"); } return StringUtils.removeEnd(siteUrl, "/") + getPermalink(); } /** Creates paths appropriate for the given {@code site}. */ @SuppressWarnings("deprecation") public Set<Path> createPaths(Site site) { Object object = getOriginalObject(); Set<Path> paths = new LinkedHashSet<Path>(); Template template = as(Template.ObjectModification.class).getDefault(); if (object instanceof Item) { paths.add(new Path(site, ((Item) object).createPermalink(site), PathType.PERMALINK)); } if (template != null) { paths.addAll(template.makePaths(site, object)); } return paths; } } /** Static utility methods. */ public static final class Static { private Static() { } /** * Finds the object associated with the given {@code site} and * {@code path}. * * @param site May be {@code null}. * @param path If {@code null}, returns {@code null}. * @return May be {@code null}. */ public static Object findByPath(Site site, String path) { path = normalizePath(path); if (path == null) { return null; } path = path.substring(0, path.length() - 1); int slashAt = path.lastIndexOf("/"); if (slashAt > -1) { String name = path.substring(slashAt + 1); path = path.substring(0, slashAt + 1); Directory directory = Query .from(Directory.class) .where("path = ?", path) .first(); if (directory != null) { String rawPath = directory.getRawPath() + name; Object item = null; if (site != null) { item = findByRawPath(site.getRawPath() + rawPath); } if (item == null) { item = findByRawPath(rawPath); } return item; } } return null; } private static Object findByRawPath(String rawPath) { Set<Object> invisibles = null; while (true) { Query<Object> query = Query .fromAll() .and("cms.directory.paths = ?", rawPath); if (invisibles != null) { query.and("_id != ?", invisibles); } Object item = query.first(); if (item != null && !State.getInstance(item).isVisible()) { if (invisibles == null) { invisibles = new LinkedHashSet<Object>(); } invisibles.add(item); continue; } if (item != null) { return item; } else if (invisibles != null && !invisibles.isEmpty()) { return invisibles.iterator().next(); } else { return null; } } } /** * Finds the object associated with the given {@code site} and * {@code path}. * * @deprecated Use {@link #findByPath} instead. Note that the new * version doesn't return a directory object. */ @Deprecated public static Object findObject(Site site, String path) { path = normalizePath(path); if (path == null) { return null; } Directory directory = Query .from(Directory.class) .where("path = ?", path) .first(); if (directory != null) { return directory; } path = path.substring(0, path.length() - 1); int slashAt = path.lastIndexOf("/"); if (slashAt > -1) { String name = path.substring(slashAt + 1); path = path.substring(0, slashAt + 1); directory = Query .from(Directory.class) .where("path = ?", path) .first(); if (directory != null) { String rawPath = directory.getRawPath() + name; Object item = null; if (site != null) { item = Query.findUnique(Object.class, PATHS_FIELD, site.getRawPath() + rawPath); } if (item == null) { item = Query.findUnique(Object.class, PATHS_FIELD, rawPath); } return item; } } return null; } /** * Returns a predicate that can be used to filter out any objects * that doesn't have a path. */ public static Predicate hasPathPredicate() { return PredicateParser.Static.parse(PATHS_FIELD + " != missing"); } } /** @deprecated Use {@link ObjectModification} instead. */ @Deprecated public static final class Global { private Global() { } private static ObjectModification asMod(Object object) { return State.getInstance(object).as(ObjectModification.class); } /** @deprecated Use {@link ObjectModification#getPathsMode} instead. */ @Deprecated public static PathsMode getPathsMode(Object object) { return asMod(object).getPathsMode(); } /** @deprecated Use {@link ObjectModification#setPathsMode} instead. */ @Deprecated public static void setPathsMode(Object object, PathsMode pathsMode) { asMod(object).setPathsMode(pathsMode); } /** @deprecated Use {@link ObjectModification#getObjectName} instead. */ @Deprecated public static String getObjectName(Object object) { return asMod(object).getObjectName(); } /** @deprecated Use {@link ObjectModification#setObjectName} instead. */ @Deprecated public static void setObjectName(Object object, String objectName) { asMod(object).setObjectName(objectName); } /** @deprecated Use {@link ObjectModification#getSitePaths} instead. */ @Deprecated public static List<Path> getSitePaths(Object object, Site site) { return asMod(object).getSitePaths(site); } /** @deprecated Use {@link ObjectModification#addSitePath} instead. */ @Deprecated public static void addSitePath(Object object, Site site, String path, PathType type) { asMod(object).addSitePath(site, path, type); } /** @deprecated Use {@link ObjectModification#clearSitePaths} instead. */ @Deprecated public static void clearSitePaths(Object object, Site site) { asMod(object).clearSitePaths(site); } /** @deprecated Use {@link ObjectModification#removeSitePath} instead. */ @Deprecated public static void removeSitePath(Object object, Site site, String path) { asMod(object).removeSitePath(site, path); } /** @deprecated Use {@link ObjectModification#getSitePathType} instead. */ @Deprecated public static PathType getSitePathType(Object object, Site site, String path) { return asMod(object).getSitePathType(site, path); } /** @deprecated Use {@link ObjectModification#putSitePathType} instead. */ @Deprecated public static void putSitePathType(Object object, Site site, String path, PathType type) { asMod(object).putSitePathType(site, path, type); } /** @deprecated Use {@link ObjectModification#getSitePermalink} instead. */ @Deprecated public static String getSitePermalink(Object object, Site site) { return asMod(object).getSitePermalink(site); } /** @deprecated Use {@link ObjectModification#getPaths} instead. */ @Deprecated public static List<Path> getPaths(Object object) { return asMod(object).getPaths(); } /** @deprecated Use {@link ObjectModification#addPath} instead. */ @Deprecated public static void addPath(Object object, String path, PathType type) { asMod(object).addPath(path, type); } /** @deprecated Use {@link ObjectModification#clearPaths} instead. */ @Deprecated public static void clearPaths(Object object) { asMod(object).clearPaths(); } /** @deprecated Use {@link ObjectModification#removePath} instead. */ @Deprecated public static void removePath(Object object, String path) { asMod(object).removePath(path); } /** @deprecated Use {@link ObjectModification#getPathType} instead. */ @Deprecated public static PathType getPathType(Object object, String path) { return asMod(object).getPathType(path); } /** @deprecated Use {@link ObjectModification#putPathType} instead. */ @Deprecated public static void putPathType(Object object, String path, PathType type) { asMod(object).putPathType(path, type); } /** @deprecated Use {@link ObjectModification#getPermalink} instead. */ @Deprecated public static String getPermalink(Object object) { return asMod(object).getPermalink(); } /** @deprecated No replacement. */ @Deprecated public static String getPermalink(Object object, String prefix) { String permalink = null; for (Path path : getPaths(object)) { if (path.getType() == PathType.PERMALINK) { String current = path.getPath(); if (permalink == null) { permalink = current; } if (current.startsWith(prefix)) { return current; } } } return permalink; } } }