package com.psddev.cms.db;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Inherited;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import com.google.common.base.Joiner;
import com.psddev.dari.db.Modification;
import com.psddev.dari.db.ObjectType;
import com.psddev.dari.db.Recordable;
import com.psddev.dari.db.ReferentialText;
import com.psddev.dari.db.State;
import com.psddev.dari.util.ObjectUtils;
import com.psddev.dari.util.StringUtils;
/**
* SEO-related functions for a piece of content.
*/
public final class Seo {
/**
* Finds the most appropriate page title for the given {@code object}.
*
* @param object If {@code null}, returns {@code null}.
* @deprecated Use {@link ObjectModification#findTitle} instead.
*/
@Deprecated
public static String findTitle(Object object) {
return Static.findTitle(object);
}
/**
* Finds the most appropriate page description for the given
* {@code object}.
*
* @param object If {@code null}, returns {@code null}.
* @deprecated Use {@link ObjectModification#findDescription} instead.
*/
@Deprecated
public static String findDescription(Object object) {
return Static.findDescription(object);
}
/**
* Finds the most appropriate page keywords for the given
* {@code object}.
*
* @param object If {@code null}, returns {@code null}.
* @deprecated Use {@link ObjectModification#findKeywords} instead.
*/
@Deprecated
public static Set<String> findKeywords(Object object) {
return Static.findKeywords(object);
}
/**
* {@link Seo} utility methods.
*
* @deprecated Use the {@code find*} methods {@link ObjectModification}
* instead.
*/
@Deprecated
public static final class Static {
/**
* Finds the most appropriate page title for the given {@code object}.
*
* @param object If {@code null}, returns {@code null}.
* @deprecated Use {@link ObjectModification#findTitle} instead.
*/
@Deprecated
public static String findTitle(Object object) {
return object != null
? State.getInstance(object).as(ObjectModification.class).findTitle()
: null;
}
/**
* Finds the most appropriate page description for the given
* {@code object}.
*
* @param object If {@code null}, returns {@code null}.
* @deprecated Use {@link ObjectModification#findDescription} instead.
*/
@Deprecated
public static String findDescription(Object object) {
return object != null
? State.getInstance(object).as(ObjectModification.class).findDescription()
: null;
}
/**
* Finds the most appropriate page keywords for the given
* {@code object}.
*
* @param object If {@code null}, returns {@code null}.
* @deprecated Use {@link ObjectModification#findKeywords} instead.
*/
@Deprecated
public static Set<String> findKeywords(Object object) {
return object != null
? State.getInstance(object).as(ObjectModification.class).findKeywords()
: null;
}
}
/**
* All possible robots meta tag values.
*
* @see <a href="https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag">Robots meta tag documentation</a>
*/
public enum RobotsValue {
NOINDEX,
NOFOLLOW,
NOARCHIVE,
NOSNIPPET,
NOODP,
NOTRANSLATE,
NOIMAGEINDEX;
}
/**
* Object modification for specifying SEO-related overrides.
*/
@Recordable.BeanProperty("seo")
@Modification.FieldInternalNamePrefix("cms.seo.")
public static final class ObjectModification extends Modification<Object> {
@ToolUi.Placeholder(dynamicText = "${content.seo.findTitlePlaceholder()}", editable = true)
private String title;
@ToolUi.Placeholder(dynamicText = "${content.seo.findDescriptionPlaceholder()}", editable = true)
private String description;
private Set<String> keywords;
@ToolUi.NoteHtml("See <a href=\"https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag\" target=\"_blank\">robots meta tag documentation</a> for more information.")
private Set<RobotsValue> robots;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
/**
* @return Never {@code null}. Mutable.
*/
public Set<String> getKeywords() {
if (keywords == null) {
keywords = new LinkedHashSet<String>();
}
return keywords;
}
/**
* @param keywords May be {@code null} to clear the set.
*/
public void setKeywords(Set<String> keywords) {
this.keywords = keywords;
}
/**
* @return Never {@code null}.
*/
public Set<RobotsValue> getRobots() {
if (robots == null) {
robots = new LinkedHashSet<RobotsValue>();
}
return robots;
}
/**
* @param robots May be {@code null} to clear the set.
*/
public void setRobots(Set<RobotsValue> robots) {
this.robots = robots;
}
/**
* Finds the most appropriate page title placeholder.
*
* @return Never {@code null}.
*/
public String findTitlePlaceholder() {
State state = getState();
ObjectType type = state.getType();
if (type != null) {
for (String field : type.as(TypeModification.class).getTitleFields()) {
Object fieldTitle = state.getByPath(field);
if (fieldTitle != null) {
title = toMetaTagString(fieldTitle);
if (title != null) {
return title;
}
}
}
}
return getState().getLabel();
}
/**
* Finds the most appropriate page title.
*
* @return Never {@code null}.
*/
public String findTitle() {
String title = getTitle();
return ObjectUtils.isBlank(title) ? findTitlePlaceholder() : title;
}
// Converts the given object into a plain string that's usable
// inside a meta tag.
private static String toMetaTagString(Object object) {
String string;
if (object instanceof ReferentialText) {
StringBuilder sb = new StringBuilder();
for (Object item : (ReferentialText) object) {
if (item instanceof String) {
sb.append((String) item);
}
}
string = StringUtils.unescapeHtml(
sb.toString().replaceAll("<[^>]+>", ""));
} else if (object instanceof Recordable) {
string = ((Recordable) object).getState().getLabel();
} else {
string = object.toString();
}
if (string != null) {
string = string.trim();
if (!string.isEmpty()) {
return string;
}
}
return null;
}
/**
* Finds the most appropriate page description placeholder.
*
* @return May be {@code null}.
*/
public String findDescriptionPlaceholder() {
State state = getState();
ObjectType type = state.getType();
if (type != null) {
for (String field : type.as(TypeModification.class).getDescriptionFields()) {
Object fieldDescription = state.getByPath(field);
if (fieldDescription != null) {
description = toMetaTagString(fieldDescription);
if (description != null) {
return description;
}
}
}
}
return null;
}
/**
* Finds the most appropriate page description.
*
* @return May be {@code null}.
*/
public String findDescription() {
String description = getDescription();
return ObjectUtils.isBlank(description) ? findDescriptionPlaceholder() : description;
}
/**
* Finds the most appropriate page keywords.
*
* @return May be {@code null}. The set is ordered, and its
* {@link #toString} will return a comma-delimited string.
*/
public Set<String> findKeywords() {
@SuppressWarnings("serial")
Set<String> keywords = new LinkedHashSet<String>() {
@Override
public String toString() {
return Joiner.on(',').skipNulls().join(this);
}
};
keywords.addAll(getKeywords());
State state = getState();
ObjectType type = state.getType();
if (type != null) {
for (String field : type.as(TypeModification.class).getKeywordsFields()) {
Iterable<?> fieldKeywords = ObjectUtils.to(Iterable.class, state.getByPath(field));
if (fieldKeywords != null) {
for (Object item : fieldKeywords) {
if (item != null) {
keywords.add(toMetaTagString(item));
}
}
}
}
}
if (!keywords.isEmpty()) {
return keywords;
}
return null;
}
/**
* Finds the most appropriate robots string.
*
* @return May be {@code null}.
* @see <a href="https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag">Robots meta tag documentation</a>
*/
public String findRobotsString() {
Set<RobotsValue> robots = getRobots();
if (robots == null || robots.isEmpty()) {
return null;
}
StringBuilder string = new StringBuilder();
for (Iterator<Seo.RobotsValue> i = getRobots().iterator(); i.hasNext();) {
Seo.RobotsValue value = i.next();
string.append(value.name().toLowerCase(Locale.ENGLISH));
if (i.hasNext()) {
string.append(",");
}
}
return string.toString();
}
}
/**
* Type modification for specifying various fields that are checked to
* find SEO-related data.
*/
@Modification.FieldInternalNamePrefix("cms.seo.")
public static final class TypeModification extends Modification<ObjectType> {
private List<String> titleFields;
private List<String> descriptionFields;
private List<String> keywordsFields;
private String openGraphType;
/**
* @return Never {@code null}. Mutable.
*/
public List<String> getTitleFields() {
if (titleFields == null) {
titleFields = new ArrayList<String>();
}
return titleFields;
}
/**
* @param titleFields May be {@code null} to clear the list.
*/
public void setTitleFields(List<String> titleFields) {
this.titleFields = titleFields;
}
/**
* @return Never {@code null}. Mutable.
*/
public List<String> getDescriptionFields() {
if (descriptionFields == null) {
descriptionFields = new ArrayList<String>();
}
return descriptionFields;
}
/**
* @param descriptionFields May be {@code null} to clear the list.
*/
public void setDescriptionFields(List<String> descriptionFields) {
this.descriptionFields = descriptionFields;
}
/**
* @return Never {@code null}. Mutable.
*/
public List<String> getKeywordsFields() {
if (keywordsFields == null) {
keywordsFields = new ArrayList<String>();
}
return keywordsFields;
}
/**
* @param keywordsFields May be {@code null} to clear the list.
*/
public void setKeywordsFields(List<String> keywordsFields) {
this.keywordsFields = keywordsFields;
}
public String getOpenGraphType() {
return openGraphType;
}
public void setOpenGraphType(String openGraphType) {
this.openGraphType = openGraphType;
}
}
/**
* Specifies an array of field paths that are checked to find the page
* title from an instance of the target type.
*/
@Documented
@Inherited
@ObjectType.AnnotationProcessorClass(TitleFieldsProcessor.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface TitleFields {
String[] value();
}
private static class TitleFieldsProcessor implements ObjectType.AnnotationProcessor<TitleFields> {
@Override
public void process(ObjectType type, TitleFields annotation) {
Collections.addAll(
type.as(TypeModification.class).getTitleFields(),
annotation.value());
}
}
/**
* Specifies an array of field paths that are checked to find the page
* description from an instance of the target type.
*/
@Documented
@Inherited
@ObjectType.AnnotationProcessorClass(DescriptionFieldsProcessor.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DescriptionFields {
String[] value();
}
private static class DescriptionFieldsProcessor implements ObjectType.AnnotationProcessor<DescriptionFields> {
@Override
public void process(ObjectType type, DescriptionFields annotation) {
Collections.addAll(
type.as(TypeModification.class).getDescriptionFields(),
annotation.value());
}
}
/**
* Specifies an array of field paths that are checked to find the page
* keywords from an instance of the target type.
*/
@Documented
@Inherited
@ObjectType.AnnotationProcessorClass(KeywordsFieldsProcessor.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface KeywordsFields {
String[] value();
}
private static class KeywordsFieldsProcessor implements ObjectType.AnnotationProcessor<KeywordsFields> {
@Override
public void process(ObjectType type, KeywordsFields annotation) {
Collections.addAll(
type.as(TypeModification.class).getKeywordsFields(),
annotation.value());
}
}
/**
* Specifies the Open Graph type of the target type.
*
* @see <a href="http://ogp.me/">Open Graph protocol</a>
*/
@Documented
@Inherited
@ObjectType.AnnotationProcessorClass(OpenGraphTypeProcessor.class)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface OpenGraphType {
String value();
}
private static class OpenGraphTypeProcessor implements ObjectType.AnnotationProcessor<OpenGraphType> {
@Override
public void process(ObjectType type, OpenGraphType annotation) {
type.as(TypeModification.class).setOpenGraphType(annotation.value());
}
}
}