View Javadoc
1   /*
2    * #%L
3    * wcm.io
4    * %%
5    * Copyright (C) 2017 wcm.io
6    * %%
7    * Licensed under the Apache License, Version 2.0 (the "License");
8    * you may not use this file except in compliance with the License.
9    * You may obtain a copy of the License at
10   *
11   *      http://www.apache.org/licenses/LICENSE-2.0
12   *
13   * Unless required by applicable law or agreed to in writing, software
14   * distributed under the License is distributed on an "AS IS" BASIS,
15   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16   * See the License for the specific language governing permissions and
17   * limitations under the License.
18   * #L%
19   */
20  package io.wcm.maven.plugins.jsondlgcnv;
21  
22  import java.io.IOException;
23  import java.nio.charset.StandardCharsets;
24  import java.util.ArrayList;
25  import java.util.Iterator;
26  import java.util.LinkedHashMap;
27  import java.util.List;
28  import java.util.Map;
29  import java.util.Set;
30  import java.util.TreeMap;
31  import java.util.regex.Matcher;
32  import java.util.regex.Pattern;
33  
34  import org.apache.commons.io.FileUtils;
35  import org.apache.commons.lang3.StringUtils;
36  import org.apache.maven.plugin.logging.Log;
37  import org.apache.sling.api.resource.Resource;
38  import org.apache.sling.api.resource.ResourceResolver;
39  import org.apache.sling.api.resource.ValueMap;
40  import org.apache.sling.commons.json.JSONArray;
41  import org.apache.sling.commons.json.JSONException;
42  import org.apache.sling.commons.json.JSONObject;
43  import org.apache.sling.fsprovider.internal.mapper.ContentFile;
44  import org.apache.sling.testing.mock.sling.junit.SlingContext;
45  
46  import com.google.gson.Gson;
47  import com.google.gson.GsonBuilder;
48  import com.google.gson.JsonObject;
49  
50  /**
51   * This converter logic is heavily based on
52   * https://github.com/Adobe-Marketing-Cloud/aem-dialog-conversion/blob/master/bundles/cq-dialog-conversion/
53   * src/main/java/com/adobe/cq/dialogconversion/impl/rules/NodeBasedRewriteRule.java
54   * and uses exactly the same logic - but operations on local JSON files as output.
55   */
56  class DialogConverter {
57  
58    // pattern that matches the regex for mapped properties: ${<path>}
59    private static final Pattern MAPPED_PATTERN = Pattern.compile("^(\\!{0,1})\\$\\{(\'.*?\'|.*?)(:(.+))?\\}$");
60  
61    // special properties
62    private static final String PROPERTY_MAP_CHILDREN = "cq:rewriteMapChildren";
63    private static final String PROPERTY_IS_FINAL = "cq:rewriteFinal";
64    private static final String PROPERTY_COMMON_ATTRS = "cq:rewriteCommonAttrs";
65    private static final String PROPERTY_RENDER_CONDITION = "cq:rewriteRenderCondition";
66  
67    // special nodes
68    private static final String NN_CQ_REWRITE_PROPERTIES = "cq:rewriteProperties";
69  
70    // node names
71    private static final String NN_RENDER_CONDITION = "rendercondition";
72    private static final String NN_GRANITE_RENDER_CONDITION = "granite:rendercondition";
73    private static final String NN_GRANITE_DATA = "granite:data";
74  
75    // Granite
76    private static final String[] GRANITE_COMMON_ATTR_PROPERTIES = { "id", "rel", "class", "title", "hidden", "itemscope", "itemtype", "itemprop" };
77    private static final String RENDER_CONDITION_CORAL2_RESOURCE_TYPE_PREFIX = "granite/ui/components/foundation/renderconditions";
78    private static final String RENDER_CONDITION_CORAL3_RESOURCE_TYPE_PREFIX = "granite/ui/components/coral/foundation/renderconditions";
79    private static final String DATA_PREFIX = "data-";
80  
81    private final Rules rules;
82    private final Resource sourceRoot;
83    private final Log log;
84  
85    DialogConverter(SlingContext context, String rulesPath, Log log) {
86      this.rules = new Rules(context.resourceResolver().getResource(rulesPath));
87      this.sourceRoot = context.resourceResolver().getResource("/source");
88      this.log = log;
89    }
90  
91    /**
92     * Convert and format all JSON files with dialog definitions.
93     */
94    public void convert() {
95      traverse(sourceRoot, true);
96    }
97  
98    /**
99     * Format (but not convert) all JSON files with dialog definitions.
100    */
101   public void format() {
102     traverse(sourceRoot, false);
103   }
104 
105   private void traverse(Resource resource, boolean convert) {
106     checkRuleMatch(resource, convert);
107     Iterator<Resource> children = resource.listChildren();
108     while (children.hasNext()) {
109       traverse(children.next(), convert);
110     }
111   }
112 
113   @SuppressWarnings("null")
114   private void checkRuleMatch(Resource resource, boolean convert) {
115     Rule rule = rules.getRule(resource);
116     if (rule != null) {
117       if (log.isInfoEnabled()) {
118         log.info("Convert " + StringUtils.removeStart(resource.getPath(), "/source/") + " with rule '" + rule.getName() + "'.");
119       }
120 
121       ContentFile contentFile = resource.adaptTo(ContentFile.class);
122       try {
123         JSONObject jsonContent = new JSONObject(FileUtils.readFileToString(contentFile.getFile(), StandardCharsets.UTF_8));
124         JsonElement wrapper = getJsonElement(jsonContent, contentFile.getSubPath());
125 
126         if (convert) {
127           applyRule(wrapper, rule);
128         }
129 
130         // format json string with gson pretty print
131         Gson gson = new GsonBuilder().setPrettyPrinting().create();
132         JsonObject gsonJson = gson.fromJson(jsonContent.toString(), JsonObject.class);
133 
134         FileUtils.write(contentFile.getFile(), gson.toJson(gsonJson), StandardCharsets.UTF_8);
135       }
136       catch (JSONException | IOException ex) {
137         throw new RuntimeException(ex);
138       }
139     }
140   }
141 
142   private JsonElement getJsonElement(JSONObject json, String path) throws JSONException {
143     if (StringUtils.isEmpty(path)) {
144       return new JsonElement(json, null, null);
145     }
146     if (StringUtils.contains(path, "/")) {
147       String name = StringUtils.substringBefore(path, "/");
148       String remainder = StringUtils.substringAfter(path, "/");
149       JSONObject child = json.getJSONObject(name);
150       return getJsonElement(child, remainder);
151     }
152     else {
153       return new JsonElement(json.getJSONObject(path), path, json);
154     }
155   }
156 
157   private void applyRule(JsonElement wrapper, Rule rule) throws JSONException {
158     // check if the 'replacement' node exists
159     Resource replacement = rule.getReplacement();
160     if (replacement == null) {
161       throw new RuntimeException("The rule " + rule + " does not define a 'replacement' section.");
162     }
163     ValueMap replacementProps = replacement.getValueMap();
164 
165     // if the replacement node has no children, we replace the tree by the empty tree,
166     // i.e. we remove the original tree
167     if (!replacement.hasChildren()) {
168       wrapper.parent.remove(wrapper.key);
169       return;
170     }
171     JSONObject root = cloneJson(wrapper.element);
172 
173     // true if the replacement tree is final and all its nodes are excluded from
174     // further processing by the algorithm
175     boolean treeIsFinal = replacementProps.get(PROPERTY_IS_FINAL, false);
176 
177     // copy replacement to original tree under original name
178     Resource replacementNext = replacement.listChildren().next();
179     JSONObject copy = wrapper.element;
180     clearJson(copy);
181     copyToJson(copy, replacementNext);
182 
183     // common attribute mapping
184     if (replacementProps.containsKey(PROPERTY_COMMON_ATTRS)) {
185       addCommonAttrMappings(root, copy);
186     }
187 
188     // render condition mapping
189     if (replacementProps.containsKey(PROPERTY_RENDER_CONDITION)) {
190       if (root.has(NN_GRANITE_RENDER_CONDITION) || root.has(NN_RENDER_CONDITION)) {
191         JSONObject renderConditionRoot = root.has(NN_GRANITE_RENDER_CONDITION) ? root.getJSONObject(NN_GRANITE_RENDER_CONDITION)
192             : root.getJSONObject(NN_RENDER_CONDITION);
193         JSONObject renderConditionCopy = copy.put(NN_GRANITE_RENDER_CONDITION, renderConditionRoot);
194 
195         // convert render condition resource types recursively
196         Iterator<JSONObject> renderConditionIterator = collectTree(renderConditionCopy).iterator();
197 
198         while (renderConditionIterator.hasNext()) {
199           JSONObject renderConditionNode = renderConditionIterator.next();
200           String resourceType = renderConditionNode.getString(ResourceResolver.PROPERTY_RESOURCE_TYPE);
201           if (resourceType.startsWith(RENDER_CONDITION_CORAL2_RESOURCE_TYPE_PREFIX)) {
202             resourceType = resourceType.replace(RENDER_CONDITION_CORAL2_RESOURCE_TYPE_PREFIX, RENDER_CONDITION_CORAL3_RESOURCE_TYPE_PREFIX);
203             renderConditionNode.put(ResourceResolver.PROPERTY_RESOURCE_TYPE, resourceType);
204           }
205         }
206       }
207     }
208 
209     // collect mappings: (node in original tree) -> (node in replacement tree)
210     Map<String, JSONObject> mappings = new LinkedHashMap<>();
211     // traverse nodes of newly copied replacement tree
212     Iterator<JSONObject> nodeIterator = collectTree(copy).iterator();
213     while (nodeIterator.hasNext()) {
214       JSONObject node = nodeIterator.next();
215       // iterate over all properties
216       Map<String, Object> replacementProperties = getProperties(node);
217       Iterator<Map.Entry<String, Object>> propertyIterator = replacementProperties.entrySet().iterator();
218       JSONObject rewritePropertiesNode = null;
219 
220       if (node.has(NN_CQ_REWRITE_PROPERTIES)) {
221         rewritePropertiesNode = node.getJSONObject(NN_CQ_REWRITE_PROPERTIES);
222       }
223 
224       while (propertyIterator.hasNext()) {
225         Map.Entry<String, Object> property = propertyIterator.next();
226         // add mapping to collection
227         if (PROPERTY_MAP_CHILDREN.equals(property.getKey())) {
228           mappings.put(cleanup((String)property.getValue()), node);
229           // remove property, as we don't want it to be part of the result
230           node.remove(cleanup(property.getKey()));
231           continue;
232         }
233         // add single node to final nodes
234         if (PROPERTY_IS_FINAL.equals(property.getKey())) {
235           if (!treeIsFinal) {
236             // TODO: required?
237             //finalNodes.add(node);
238           }
239           node.remove(cleanup(property.getKey()));
240           continue;
241         }
242         // set value from original tree in case this is a mapped property
243         boolean mappedProperty = mapProperty(root, node, property.getKey(), (String)property.getValue());
244 
245         if (mappedProperty && rewritePropertiesNode != null) {
246           if (rewritePropertiesNode.has(cleanup(property.getKey()))) {
247             rewriteProperty(node, cleanup(property.getKey()), rewritePropertiesNode.getJSONArray(cleanup(property.getKey())));
248           }
249         }
250       }
251 
252       // remove <cq:rewriteProperties> node post-mapping
253       if (rewritePropertiesNode != null) {
254         node.remove(NN_CQ_REWRITE_PROPERTIES);
255       }
256 
257       // reorder children and properties
258       reorderChildrenProperties(node, root);
259     }
260 
261     // copy children from original tree to replacement tree according to the mappings found
262     for (Map.Entry<String, JSONObject> mapping : mappings.entrySet()) {
263       String key = cleanup(mapping.getKey());
264       if (!root.has(cleanup(key))) {
265         // the node specified in the mapping does not exist in the original tree
266         continue;
267       }
268       JSONObject source = root.getJSONObject(cleanup(key));
269       JSONObject destination = mapping.getValue();
270       Iterator<Map.Entry<String,JSONObject>> iterator = getChildren(source).entrySet().iterator();
271       // copy over the source's children to the destination
272       while (iterator.hasNext()) {
273         Map.Entry<String,JSONObject> child = iterator.next();
274         destination.put(cleanup(child.getKey()), child.getValue());
275       }
276     }
277 
278     // TODO: not required?
279     /*
280     // we add the complete subtree to the final nodes
281     if (treeIsFinal) {
282       nodeIterator = collectTree(copy).iterator();
283       while (nodeIterator.hasNext()) {
284         finalNodes.add(nodeIterator.next());
285       }
286     }
287     */
288   }
289 
290   /**
291    * Replaces the value of a mapped property with a value from the original tree.
292    * @param root the root node of the original tree
293    * @param node the replacement tree object
294    * @param key property name of the (potentially) mapped property in the replacement copy tree
295    * @return true if there was a successful mapping, false otherwise
296    */
297   private boolean mapProperty(JSONObject root, JSONObject node, String key, String... mapping) throws JSONException {
298     boolean deleteProperty = false;
299     for (String value : mapping) {
300       Matcher matcher = MAPPED_PATTERN.matcher(value);
301       if (matcher.matches()) {
302         // this is a mapped property, we will delete it if the mapped destination
303         // property doesn't exist
304         deleteProperty = true;
305         String path = matcher.group(2);
306         // unwrap quoted property paths
307         path = StringUtils.removeStart(StringUtils.stripEnd(path, "\'"), "\'");
308         if (root.has(cleanup(path))) {
309           // replace property by mapped value in the original tree
310           Object originalValue = root.get(cleanup(path));
311           node.put(cleanup(key), originalValue);
312 
313           // negate boolean properties if negation character has been set
314           String negate = matcher.group(1);
315           if ("!".equals(negate) && (originalValue instanceof Boolean)) {
316             node.put(cleanup(key), !((Boolean)originalValue));
317           }
318 
319           // the mapping was successful
320           deleteProperty = false;
321           break;
322         }
323         else {
324           String defaultValue = matcher.group(4);
325           if (defaultValue != null) {
326             node.put(cleanup(key), defaultValue);
327             deleteProperty = false;
328             break;
329           }
330         }
331       }
332     }
333     if (deleteProperty) {
334       // mapped destination does not exist, we don't include the property in replacement tree
335       node.remove(key);
336       return false;
337     }
338 
339     return true;
340   }
341 
342   /**
343    * Applies a string rewrite to a property.
344    * @param node Node
345    * @param key the property name to rewrite
346    * @param rewriteProperty the property that defines the string rewrite
347    */
348   private void rewriteProperty(JSONObject node, String key, JSONArray rewriteProperty) throws JSONException {
349     if (node.get(cleanup(key)) instanceof String) {
350       if (rewriteProperty.length() == 2) {
351         if (rewriteProperty.get(0) instanceof String && rewriteProperty.get(1) instanceof String) {
352           String pattern = rewriteProperty.getString(0);
353           String replacement = rewriteProperty.getString(1);
354 
355           Pattern compiledPattern = Pattern.compile(pattern);
356           Matcher matcher = compiledPattern.matcher(node.getString(cleanup(key)));
357           node.put(cleanup(key), matcher.replaceAll(replacement));
358         }
359       }
360     }
361   }
362 
363   /**
364    * Adds property mappings on a replacement node for Granite common attributes.
365    * @param root the root node
366    * @param node the replacement node
367    */
368   private void addCommonAttrMappings(JSONObject root, JSONObject node) throws JSONException {
369     for (String property : GRANITE_COMMON_ATTR_PROPERTIES) {
370       String[] mapping = { "${./" + property + "}", "${\'./granite:" + property + "\'}" };
371       mapProperty(root, node, "granite:" + property, mapping);
372     }
373 
374     if (root.has(NN_GRANITE_DATA)) {
375       // the root has granite:data defined, copy it before applying data-* properties
376       node.put(NN_GRANITE_DATA, root.get(NN_GRANITE_DATA));
377     }
378 
379     // map data-* prefixed properties to granite:data child
380     for (Map.Entry<String, Object> entry : getProperties(root).entrySet()) {
381       if (!StringUtils.startsWith(entry.getKey(), DATA_PREFIX)) {
382         continue;
383       }
384 
385       // add the granite:data child if necessary
386       JSONObject dataNode;
387       if (!node.has(NN_GRANITE_DATA)) {
388         dataNode = new JSONObject();
389         node.put(NN_GRANITE_DATA, dataNode);
390       }
391       else {
392         dataNode = node.getJSONObject(NN_GRANITE_DATA);
393       }
394 
395       // set up the property mapping
396       String nameWithoutPrefix = entry.getKey().substring(DATA_PREFIX.length());
397       mapProperty(root, dataNode, nameWithoutPrefix, "${./" + entry.getKey() + "}");
398     }
399   }
400 
401   private JSONObject cloneJson(JSONObject item) throws JSONException {
402     JSONObject newItem = new JSONObject();
403 
404     Set<Map.Entry<String, Object>> props = getProperties(item).entrySet();
405     for (Map.Entry<String, Object> prop : props) {
406       newItem.put(prop.getKey(), prop.getValue());
407     }
408 
409     Set<Map.Entry<String, JSONObject>> children = getChildren(item).entrySet();
410     for (Map.Entry<String, JSONObject> child : children) {
411       newItem.put(child.getKey(), cloneJson(child.getValue()));
412     }
413 
414     return newItem;
415   }
416 
417   private void clearJson(JSONObject item) throws JSONException {
418     Set<Map.Entry<String, Object>> props = getProperties(item).entrySet();
419     Set<Map.Entry<String, JSONObject>> children = getChildren(item).entrySet();
420     for (Map.Entry<String, Object> prop : props) {
421       item.remove(prop.getKey());
422     }
423     for (Map.Entry<String, JSONObject> child : children) {
424       item.remove(child.getKey());
425     }
426   }
427 
428   private void copyToJson(JSONObject dest, Resource resource) throws JSONException {
429     for (Map.Entry<String, Object> entry : resource.getValueMap().entrySet()) {
430       if (StringUtils.equals(cleanup(entry.getKey()), "jcr:primaryType")) {
431         continue;
432       }
433       dest.put(cleanup(entry.getKey()), entry.getValue());
434     }
435 
436     Iterator<Resource> children = resource.listChildren();
437     while (children.hasNext()) {
438       Resource child = children.next();
439       JSONObject childObject = new JSONObject();
440       copyToJson(childObject, child);
441       dest.put(child.getName(), childObject);
442     }
443   }
444 
445   private List<JSONObject> collectTree(JSONObject item) throws JSONException {
446     List<JSONObject> items = new ArrayList<>();
447     items.add(item);
448     for (JSONObject child : getChildren(item).values()) {
449       items.addAll(collectTree(child));
450     }
451     return items;
452   }
453 
454   private Map<String, Object> getProperties(JSONObject item) throws JSONException {
455     Map<String, Object> props = new LinkedHashMap<>();
456     JSONArray names = item.names();
457     if (names != null) {
458       for (int i = 0; i < names.length(); i++) {
459         String name = names.getString(i);
460         Object value = item.get(name);
461         if (!(value instanceof JSONObject)) {
462           props.put(name, value);
463         }
464       }
465     }
466     return props;
467   }
468 
469   private Map<String, JSONObject> getChildren(JSONObject item) throws JSONException {
470     Map<String, JSONObject> children = new LinkedHashMap<>();
471     JSONArray names = item.names();
472     if (names != null) {
473       for (int i = 0; i < names.length(); i++) {
474         String name = names.getString(i);
475         Object value = item.get(name);
476         if (value instanceof JSONObject) {
477           children.put(name, (JSONObject)value);
478         }
479       }
480     }
481     return children;
482   }
483 
484   private String cleanup(String name) {
485     return StringUtils.removeStart(name, "./");
486   }
487 
488   private void reorderChildrenProperties(JSONObject item, JSONObject orginal) throws JSONException {
489     Map<String, Object> props = new TreeMap<String, Object>(getProperties(item));
490     Map<String, JSONObject> children = getChildren(item);
491     for (String key : props.keySet()) {
492       item.remove(key);
493     }
494     for (String key : children.keySet()) {
495       item.remove(key);
496     }
497     // put all properties and items that where already present in original node back in same order
498     for (String key : getProperties(orginal).keySet()) {
499       if (props.containsKey(key)) {
500         item.put(key, props.get(key));
501         props.remove(key);
502       }
503     }
504     // put all other props in alphabetical order
505     for (Map.Entry<String, Object> entry : props.entrySet()) {
506       item.put(entry.getKey(), entry.getValue());
507     }
508     // put all children that where already present in original node back in same order
509     for (String key : getProperties(orginal).keySet()) {
510       if (children.containsKey(key)) {
511         item.put(key, children.get(key));
512         children.remove(key);
513       }
514     }
515     // put all other children in alphabetical order
516     for (Map.Entry<String, JSONObject> entry : children.entrySet()) {
517       item.put(entry.getKey(), entry.getValue());
518     }
519   }
520 
521 
522   private static class JsonElement {
523 
524     private final JSONObject element;
525     private final String key;
526     private final JSONObject parent;
527 
528     JsonElement(JSONObject element, String key, JSONObject parent) {
529       this.element = element;
530       this.key = key;
531       this.parent = parent;
532     }
533 
534   }
535 
536 }