Author: fmeschbe
Date: Fri Mar 27 14:30:06 2015
New Revision: 1669598

URL: http://svn.apache.org/r1669598
Log:
SLING-4543 Add support for JSON dictionaries

Applying patch by Alex Klimetscheck (thanks alot !)

Modified:
    
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java
    
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
    
sling/trunk/contrib/extensions/i18n/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleTest.java

Modified: 
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java
URL: 
http://svn.apache.org/viewvc/sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java?rev=1669598&r1=1669597&r2=1669598&view=diff
==============================================================================
--- 
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java
 (original)
+++ 
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundle.java
 Fri Mar 27 14:30:06 2015
@@ -18,12 +18,14 @@
  */
 package org.apache.sling.i18n.impl;
 
+import java.io.IOException;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Enumeration;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
+import java.util.LinkedHashMap;
 import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
@@ -31,9 +33,12 @@ import java.util.Map;
 import java.util.ResourceBundle;
 import java.util.Set;
 
+import org.apache.jackrabbit.commons.json.JsonHandler;
+import org.apache.jackrabbit.commons.json.JsonParser;
 import org.apache.jackrabbit.util.ISO9075;
 import org.apache.sling.api.SlingException;
 import org.apache.sling.api.resource.Resource;
+import org.apache.sling.api.resource.ResourceMetadata;
 import org.apache.sling.api.resource.ResourceResolver;
 import org.apache.sling.api.resource.ResourceUtil;
 import org.apache.sling.api.resource.ValueMap;
@@ -74,17 +79,19 @@ public class JcrResourceBundle extends R
             ResourceResolver resourceResolver) {
         this.locale = locale;
 
+        log.info("Finding all dictionaries for '{}' (basename: {}) ...", 
locale, baseName == null ? "<none>" : baseName);
+
         long start = System.currentTimeMillis();
         refreshSession(resourceResolver);
         Set<String> roots = loadPotentialLanguageRoots(resourceResolver, 
locale, baseName);
         this.resources = loadFully(resourceResolver, roots, 
this.languageRoots);
+
         long end = System.currentTimeMillis();
-        if (log.isDebugEnabled()) {
-            log.debug(
-                "JcrResourceBundle: Fully loaded {} entries for {} (base: {}) 
in {}ms",
-                new Object[] { resources.size(), locale, baseName,
-                    (end - start) });
-            log.debug("JcrResourceBundle: Language roots: {}", languageRoots);
+        if (log.isInfoEnabled()) {
+            log.info(
+                "Finished loading {} entries for '{}' (basename: {}) in {}ms",
+                new Object[] { resources.size(), locale, baseName == null ? 
"<none>" : baseName, (end - start)}
+            );
         }
     }
 
@@ -139,11 +146,11 @@ public class JcrResourceBundle extends R
      * Therefore this method must not be called concurrently or the set
      * must either be thread safe.
      *
-     * @param resourceResolver The storage access (must not be {@code null})
-     * @param roots The set of (potential) disctionary subtrees. This must
+     * @param resolver The storage access (must not be {@code null})
+     * @param roots The set of (potential) dictionary subtrees. This must
      *      not be {@code null}. If empty, no resources will actually be
      *      loaded.
-     * @param languageRoots The set of actualy dictionary subtrees. While
+     * @param languageRoots The set of actually dictionary subtrees. While
      *      processing the resources, all subtrees listed in the {@code roots}
      *      set is added to this set if it actually contains resources. This
      *      must not be {@code null}.
@@ -152,83 +159,169 @@ public class JcrResourceBundle extends R
      * @throws NullPointerException if either of the parameters is {@code 
null}.
      */
     @SuppressWarnings("deprecation")
-    private Map<String, Object> loadFully(final ResourceResolver 
resourceResolver, Set<String> roots, Set<String> languageRoots) {
-        final List<List<Map<String, Object>>> allResources = new 
ArrayList<List<Map<String,Object>>>();
+    private Map<String, Object> loadFully(final ResourceResolver resolver, 
Set<String> roots, Set<String> languageRoots) {
 
-        final String[] path = resourceResolver.getSearchPath();
+        final String[] searchPath = resolver.getSearchPath();
 
-        for (final String root: roots) {
-            String fullLoadQuery = String.format(QUERY_MESSAGES_FORMAT, 
ISO9075.encodePath(root));
+        // for each search path entry, have a list of maps (dictionaries)
+        // plus other = "outside the search path" at the end
 
-            log.debug("Executing full load query {}", fullLoadQuery);
+        //   [0] /apps2  -> [dict1, dict2, dict3 ...]
+        //   [1] /apps   -> [dict4, dict5, ...]
+        //   [2] /libs   -> [dict6, ...]
+        //   [3] (other) -> [dict7, dict8 ...]
 
-            // do an XPath query because this won't go away soon and still
-            // (2011/04/04) is the fastest query language ...
-            Iterator<Map<String, Object>> bundles = null;
-            try {
-                bundles = resourceResolver.queryResources(fullLoadQuery, 
"xpath");
-            } catch (final SlingException se) {
-                log.error("Exception during resource query " + fullLoadQuery, 
se);
+        List<List<Map<String, Object>>> dictionariesBySearchPath = new 
ArrayList<List<Map<String, Object>>>(searchPath.length + 1);
+        for (int i = 0; i < searchPath.length + 1; i++) {
+            dictionariesBySearchPath.add(new ArrayList<Map<String, Object>>());
+        }
+
+        for (final String root: roots) {
+
+            Resource dictionaryResource = resolver.getResource(root);
+            if (dictionaryResource == null) {
+                log.warn("Dictionary root found by search not accessible: {}", 
root);
+                continue;
             }
 
-            if ( bundles != null ) {
+            // linked hash map to keep order (not functionally important, but 
helpful for dictionary debugging)
+            Map<String, Object> dictionary = new LinkedHashMap<String, 
Object>();
 
-                final Map<String, Object> rest = new HashMap<String, Object>();
-                final List<Map<String, Object>> res0 = new 
ArrayList<Map<String, Object>>();
-                for (int i = 0; i < path.length; i++) {
-                    res0.add(new HashMap<String, Object>());
+            // find where in the search path this dict belongs
+            // otherwise put it in the outside-the-search-path bucket (last 
list)
+            List<Map<String, Object>> targetList = 
dictionariesBySearchPath.get(searchPath.length);
+            for (int i = 0; i < searchPath.length; i++) {
+                if (root.startsWith(searchPath[i])) {
+                    targetList = dictionariesBySearchPath.get(i);
+                    break;
                 }
-                res0.add(rest); // add global list at the end
+            }
+            targetList.add(dictionary);
 
-                allResources.add(res0);
+            // check type of dictionary
+            if (dictionaryResource.getName().endsWith(".json")) {
+                loadJsonDictionary(dictionaryResource, dictionary);
+            } else {
+                loadSlingMessageDictionary(resolver, root, dictionary);
+            }
 
-                while (bundles.hasNext()) {
-                    final Map<String, Object> row = bundles.next();
-                    if (row.containsKey(PROP_VALUE)) {
-                        final String jcrPath = (String) row.get(JCR_PATH);
-                        String key = (String) row.get(PROP_KEY);
-
-                        if (key == null) {
-                            key = ResourceUtil.getName(jcrPath);
-                        }
-
-                        Map<String, Object> dst = rest;
-                        for (int i = 0; i < path.length; i++) {
-                            if (jcrPath.startsWith(path[i])) {
-                                dst = res0.get(i);
-                                break;
-                            }
-                        }
+            if (!dictionary.isEmpty()) {
+                languageRoots.add(root);
+            }
+        }
 
-                        dst.put(key, row.get(PROP_VALUE));
-                    }
+        // linked hash map to keep order (not functionally important, but 
helpful for dictionary debugging)
+        final Map<String, Object> result = new LinkedHashMap<String, Object>();
+
+        // first, add everything that's not under a search path (e.g. /content)
+        // below, same strings inside a search path dictionary would overlay 
them since
+        // they are added later to result = overwrite
+        for (Map<String, Object> dict : 
dictionariesBySearchPath.get(searchPath.length)) {
+            result.putAll(dict);
+        }
+
+        // then, in order of the search path, add all the individual 
dictionaries into
+        // a single result, so that e.g. strings in /apps overlay the ones in 
/libs
+        for (int i = searchPath.length - 1; i >= 0; i--) {
+
+            for (Map<String, Object> dict : dictionariesBySearchPath.get(i)) {
+                result.putAll(dict);
+            }
+        }
+
+        return result;
+    }
+
+    private void loadJsonDictionary(Resource resource, final Map<String, 
Object> targetDictionary) {
+        log.info("Loading json dictionary: {}", resource.getPath());
+
+        // use streaming parser (we don't need the dict in memory twice)
+        JsonParser parser = new JsonParser(new JsonHandler() {
+
+            private String key;
+
+            @Override
+            public void key(String key) throws IOException {
+                this.key = key;
+            }
+
+            @Override
+            public void value(String value) throws IOException {
+                targetDictionary.put(key, value);
+            }
+
+            @Override
+            public void object() throws IOException {}
+            @Override
+            public void endObject() throws IOException {}
+            @Override
+            public void array() throws IOException {}
+            @Override
+            public void endArray() throws IOException {}
+            @Override
+            public void value(boolean value) throws IOException {}
+            @Override
+            public void value(long value) throws IOException {}
+            @Override
+            public void value(double value) throws IOException {}
+        });
+
+        InputStream stream = resource.adaptTo(InputStream.class);
+        if (stream != null) {
+            String encoding = "utf-8";
+            ResourceMetadata metadata = resource.getResourceMetadata();
+            if (metadata != null) { // test does not implement metadata
+                if (metadata.getCharacterEncoding() != null) {
+                    encoding = metadata.getCharacterEncoding();
                 }
+            }
 
-                for (int i = res0.size() - 1; i >= 0; i--) {
-                    final Map<String, Object> resources = res0.get(i);
-                    if (!resources.isEmpty()) {
-                        // also remember root (in case we face a non-empty map)
-                        languageRoots.add(root);
-                        break;
-                    }
+            try {
+
+                parser.parse(stream, encoding);
+
+            } catch (IOException e) {
+                log.warn("Could not parse i18n json dictionary {}: {}", 
resource.getPath(), e.getMessage());
+            } finally {
+                try {
+                    stream.close();
+                } catch (IOException ignore) {
                 }
             }
+        } else {
+            log.warn("Not a json file: {}", resource.getPath());
         }
-        final Map<String, Object> result = new HashMap<String, Object>();
-        for(final List<Map<String, Object>> current : allResources) {
-            final Map<String, Object> rest = current.get(current.size() - 1);
-            result.putAll(rest);
-        }
+    }
+
+    private void loadSlingMessageDictionary(ResourceResolver resourceResolver, 
String path, Map<String, Object> targetDictionary) {
+        // run query for sling:Message nodes
+        String dictQuery = String.format(QUERY_MESSAGES_FORMAT, 
ISO9075.encodePath(path));
+
+        log.info("Loading sling:Message dictionary: {}", path);
+        log.info("Executing query {}", dictQuery);
+
+        try {
+            // do an XPath query because this won't go away soon and still
+            // (2011/04/04) is the fastest query language ...
+            Iterator<Map<String, Object>> queryResult = 
resourceResolver.queryResources(dictQuery, "xpath");
 
-        for (int i = path.length - 1; i >= 0; i--) {
+            while (queryResult.hasNext()) {
+                final Map<String, Object> row = queryResult.next();
+                if (row.containsKey(PROP_VALUE)) {
+                    final String jcrPath = (String) row.get(JCR_PATH);
+                    String key = (String) row.get(PROP_KEY);
 
-            for(final List<Map<String, Object>> current : allResources) {
-                final Map<String, Object> resources = current.get(i);
-                result.putAll(resources);
+                    if (key == null) {
+                        key = ResourceUtil.getName(jcrPath);
+                    }
+
+                    targetDictionary.put(key, row.get(PROP_VALUE));
+                }
             }
-        }
 
-        return result;
+        } catch (final SlingException se) {
+            log.error("Exception during resource query " + dictQuery, se);
+        }
     }
 
     private Set<String> loadPotentialLanguageRoots(ResourceResolver 
resourceResolver, Locale locale, String baseName) {

Modified: 
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
URL: 
http://svn.apache.org/viewvc/sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java?rev=1669598&r1=1669597&r2=1669598&view=diff
==============================================================================
--- 
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
 (original)
+++ 
sling/trunk/contrib/extensions/i18n/src/main/java/org/apache/sling/i18n/impl/JcrResourceBundleProvider.java
 Fri Mar 27 14:30:06 2015
@@ -324,6 +324,7 @@ public class JcrResourceBundleProvider i
         final Set<String> languageRoots = 
resourceBundle.getLanguageRootPaths();
         languageRootPaths.addAll(languageRoots);
         log.debug("registerResourceBundle({}, ...): added service registration 
and language roots {}", key, languageRoots);
+        log.info("Currently loaded dictionaries across all locales: {}", 
languageRootPaths);
     }
 
     /**

Modified: 
sling/trunk/contrib/extensions/i18n/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleTest.java
URL: 
http://svn.apache.org/viewvc/sling/trunk/contrib/extensions/i18n/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleTest.java?rev=1669598&r1=1669597&r2=1669598&view=diff
==============================================================================
--- 
sling/trunk/contrib/extensions/i18n/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleTest.java
 (original)
+++ 
sling/trunk/contrib/extensions/i18n/src/test/java/org/apache/sling/i18n/impl/JcrResourceBundleTest.java
 Fri Mar 27 14:30:06 2015
@@ -18,6 +18,8 @@
  */
 package org.apache.sling.i18n.impl;
 
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
 import java.util.Enumeration;
 import java.util.HashMap;
 import java.util.Iterator;
@@ -25,6 +27,7 @@ import java.util.LinkedHashMap;
 import java.util.Locale;
 import java.util.Map;
 
+import javax.jcr.Binary;
 import javax.jcr.Node;
 import javax.jcr.NodeIterator;
 import javax.jcr.RepositoryException;
@@ -35,6 +38,7 @@ import javax.jcr.query.RowIterator;
 import javax.naming.NamingException;
 import javax.servlet.http.HttpServletRequest;
 
+import org.apache.jackrabbit.JcrConstants;
 import org.apache.sling.api.resource.AbstractResource;
 import org.apache.sling.api.resource.PersistenceException;
 import org.apache.sling.api.resource.Resource;
@@ -85,62 +89,7 @@ public class JcrResourceBundleTest exten
                         @Override
                         public Resource next() {
                             final Node node = nodes.nextNode();
-                            final Resource rsrc = new AbstractResource() {
-
-                                @Override
-                                public String getResourceType() {
-                                    // TODO Auto-generated method stub
-                                    return null;
-                                }
-
-                                @Override
-                                public String getResourceSuperType() {
-                                    // TODO Auto-generated method stub
-                                    return null;
-                                }
-
-                                @Override
-                                public ResourceResolver getResourceResolver() {
-                                    // TODO Auto-generated method stub
-                                    return null;
-                                }
-
-                                @Override
-                                public ResourceMetadata getResourceMetadata() {
-                                    // TODO Auto-generated method stub
-                                    return null;
-                                }
-
-                                @Override
-                                public String getPath() {
-                                    try {
-                                        return node.getPath();
-                                    } catch ( final RepositoryException re ) {
-                                        throw new RuntimeException(re);
-                                    }
-                                }
-
-                                @Override
-                                public <AdapterType> AdapterType adaptTo(
-                                        Class<AdapterType> type) {
-                                    if ( type == ValueMap.class) {
-                                        try {
-                                            final Map<String, Object> props = 
new HashMap<String, Object>();
-                                            if ( 
node.hasProperty(JcrResourceBundle.PROP_LANGUAGE) ) {
-                                                
props.put(JcrResourceBundle.PROP_LANGUAGE, 
node.getProperty(JcrResourceBundle.PROP_LANGUAGE).getString());
-                                            }
-                                            if ( 
node.hasProperty(JcrResourceBundle.PROP_BASENAME) ) {
-                                                
props.put(JcrResourceBundle.PROP_BASENAME, 
node.getProperty(JcrResourceBundle.PROP_BASENAME).getString());
-                                            }
-                                            return (AdapterType)new 
ValueMapDecorator(props);
-                                        } catch ( final RepositoryException re 
) {
-                                            throw new RuntimeException(re);
-                                        }
-                                    }
-                                    return super.adaptTo(type);
-                                }
-                            };
-                            return rsrc;
+                            return new TestResource(node);
                         }
 
                         @Override
@@ -163,7 +112,17 @@ public class JcrResourceBundleTest exten
 
             @Override
             public Resource getResource(String path) {
-                // TODO Auto-generated method stub
+                try {
+                    Node n = getSession().getNode(path);
+                    if (n != null) {
+                        return new TestResource(n);
+                    }
+
+                } catch (NamingException ne) {
+                     //ignore
+                } catch (RepositoryException re) {
+                    //ignore
+                }
                 return null;
             }
 
@@ -550,6 +509,52 @@ public class JcrResourceBundleTest exten
         assertEquals(MESSAGES_DE.size(), counter);
     }
 
+    public void test_outside_search_path() throws Exception {
+        Node libsI18n = getSession().getRootNode().getNode("libs/i18n");
+        libsI18n.remove();
+
+        // dict outside search path: /content
+        Node contentI18n = 
getSession().getRootNode().addNode("content").addNode("i18n", 
"nt:unstructured");
+        Node de = contentI18n.addNode("de", "nt:folder");
+        de.addMixin("mix:language");
+        de.setProperty("jcr:language", "de");
+        for (Message msg : MESSAGES_DE.values()) {
+            msg.add(de);
+        }
+        getSession().save();
+
+        // test if /content dictionary is read at all
+        JcrResourceBundle bundle = new JcrResourceBundle(new Locale("de"), 
null, resolver);
+        for (Message msg : MESSAGES_DE.values()) {
+            assertEquals(msg.message, bundle.getString(msg.key));
+        }
+
+        // now overwrite /content dict in /libs
+        libsI18n = getSession().getRootNode().getNode("libs").addNode("i18n", 
"nt:unstructured");
+        de = libsI18n.addNode("de", "nt:folder");
+        de.addMixin("mix:language");
+        de.setProperty("jcr:language", "de");
+        for (Message msg : MESSAGES_DE_APPS.values()) {
+            msg.add(de);
+        }
+        getSession().save();
+
+        // test if /libs (something in the search path) overlays /content 
(outside the search path)
+        bundle = new JcrResourceBundle(new Locale("de"), null, resolver);
+        for (Message msg : MESSAGES_DE_APPS.values()) {
+            assertEquals(msg.message, bundle.getString(msg.key));
+        }
+
+        // test getKeys
+        Enumeration<String> keys = bundle.getKeys();
+        int counter = 0;
+        while (keys.hasMoreElements()) {
+            counter++;
+            String key = keys.nextElement();
+            assertTrue("bundle returned key that is not supposed to be there: 
" + key, MESSAGES_DE_APPS.containsKey(key));
+        }
+        assertEquals(MESSAGES_DE.size(), counter);
+    }
 
     public void test_basename() throws Exception {
         // create another de lib with a basename set
@@ -579,4 +584,119 @@ public class JcrResourceBundleTest exten
         }
         assertEquals(MESSAGES_DE.size(), counter);
     }
+
+    public void test_json_dictionary() throws Exception {
+        Node appsI18n = 
getSession().getRootNode().addNode("apps").addNode("i18n", "nt:unstructured");
+        Node deJson = appsI18n.addNode("de.json", "nt:file");
+        deJson.addMixin("mix:language");
+        deJson.setProperty("jcr:language", "de");
+        Node content = deJson.addNode("jcr:content", "nt:resource");
+        content.setProperty("jcr:mimeType", "application/json");
+
+        // manually creating json file, good enough for the test
+        StringBuilder json = new StringBuilder();
+        json.append("{");
+        for (Message msg : MESSAGES_DE_APPS.values()) {
+            json.append("\"").append(msg.key).append("\": \"");
+            json.append(msg.message).append("\",\n");
+        }
+        json.append("}");
+
+        InputStream stream = new 
ByteArrayInputStream(json.toString().getBytes());
+        Binary binary = getSession().getValueFactory().createBinary(stream);
+        content.setProperty("jcr:data", binary);
+        getSession().save();
+
+        // test getString
+        JcrResourceBundle bundle = new JcrResourceBundle(new Locale("de"), 
null, resolver);
+        for (Message msg : MESSAGES_DE_APPS.values()) {
+            assertEquals(msg.message, bundle.getString(msg.key));
+        }
+
+        // test getKeys
+        Enumeration<String> keys = bundle.getKeys();
+        int counter = 0;
+        while (keys.hasMoreElements()) {
+            counter++;
+            String key = keys.nextElement();
+            assertTrue("bundle returned key that is not supposed to be there: 
" + key, MESSAGES_DE_APPS.containsKey(key));
+        }
+        assertEquals(MESSAGES_DE.size(), counter);
+    }
+
+    private class TestResource extends AbstractResource {
+
+        private Node node = null;
+        public TestResource(Node xnode) {
+            super();
+            node = xnode;
+        }
+
+        @Override
+        public String getResourceType() {
+            // TODO Auto-generated method stub
+            return null;
+        }
+
+        @Override
+        public String getResourceSuperType() {
+            // TODO Auto-generated method stub
+            return null;
+        }
+
+        @Override
+        public ResourceResolver getResourceResolver() {
+            // TODO Auto-generated method stub
+            return null;
+        }
+
+        @Override
+        public ResourceMetadata getResourceMetadata() {
+            // TODO Auto-generated method stub
+            return null;
+        }
+
+        @Override
+        public String getPath() {
+            try {
+                return node.getPath();
+            } catch ( final RepositoryException re ) {
+                throw new RuntimeException(re);
+            }
+        }
+
+        @Override
+        public <AdapterType> AdapterType adaptTo(
+                Class<AdapterType> type) {
+            if ( type == ValueMap.class) {
+                try {
+                    final Map<String, Object> props = new HashMap<String, 
Object>();
+                    if ( node.hasProperty(JcrResourceBundle.PROP_LANGUAGE) ) {
+                        props.put(JcrResourceBundle.PROP_LANGUAGE, 
node.getProperty(JcrResourceBundle.PROP_LANGUAGE).getString());
+                    }
+                    if ( node.hasProperty(JcrResourceBundle.PROP_BASENAME) ) {
+                        props.put(JcrResourceBundle.PROP_BASENAME, 
node.getProperty(JcrResourceBundle.PROP_BASENAME).getString());
+                    }
+                    return (AdapterType)new ValueMapDecorator(props);
+                } catch ( final RepositoryException re ) {
+                    throw new RuntimeException(re);
+                }
+            }
+            if (type == InputStream.class) {
+                try {
+                    if (node.hasNode(JcrConstants.JCR_CONTENT)) {
+                        return (AdapterType) 
node.getNode(JcrConstants.JCR_CONTENT).getProperty(JcrConstants.JCR_DATA).getBinary().getStream();
+                    } else {
+                        return null;
+                    }
+                } catch (RepositoryException e) {
+                    throw new RuntimeException(e);
+                }
+            }
+            if (type == Node.class) {
+                return (AdapterType)node;
+            }
+            return super.adaptTo(type);
+        }
+    };
 }


Reply via email to