/** * Copyright 2010 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ package org.waveprotocol.wave.client.gadget; import com.google.gwt.core.client.GWT; import com.google.gwt.core.client.JavaScriptObject; import org.waveprotocol.wave.model.util.ReadableStringMap; import org.waveprotocol.wave.model.util.ReadableStringMap.ProcV; /** * Overlay and JSON-converter class to hold key-value pairs. Works as * String-to-String map. The map may also contain null values in delta maps to * indicate keys to be deleted. The implementation makes sure that only * string and null values are present at any time. The keys are internally * prepended with ":" to avoid overriding JS object properties. * TODO(user): Some elements are borrowed from the StringMap class. Consider * unifying the JS map classes. * */ public class StateMap extends JavaScriptObject { /** * A procedure that accepts a key and the corresponding value from the map, * does something with them, and returns a boolean condition. * * @see StateMap#checkKeyValue(StateMap.CheckKeyValue) Implement this class to * iterate over key-value pairs until either all key-value pairs are * observed or the returned condition is false using the checkKeyValue * method. */ public static interface CheckKeyValue { /** The function */ public boolean check(String key, String value); } /** * A procedure that accepts a key and the corresponding value from the map and * does something with them, but does not require to return a boolean * condition. * * Implement this interface to iterate over all key-value pairs using each * method. */ public static interface Each { /** The procedure */ public void apply(String key, String value); } /** * Helper class that implements CheckKeyValue to compare received key-value * pairs against key-values in a given StateMap. */ public static class KeyValueComparator implements CheckKeyValue { private final StateMap otherMap; /** * Constructs the comparator for a given map. * * @param otherMap the map to compare key-values against. */ public KeyValueComparator(StateMap otherMap) { this.otherMap = otherMap; } @Override public boolean check(String key, String value) { return (value == null) ? otherMap.has(key) && (otherMap.get(key) == null) : value.equals(otherMap.get(key)); } } /** * External construction is banned. */ protected StateMap() { } /** * Creates gadget state object. */ public static StateMap create() { return JavaScriptObject.createObject().cast(); } /** * Creates gadget state object from a String-to-String map. */ public static StateMap createFromStringMap(ReadableStringMap<String> map) { final StateMap stateMap = JavaScriptObject.createObject().cast(); map.each(new ProcV<String>() { @Override public void apply(String key, String value) { stateMap.put(key, value); } }); return stateMap; } /** * Checks whether the key is in the map. * * @param key the key. * @return true if a value indexed by the given key exists in the map. */ public final native boolean has(String key) /*-{ return this.hasOwnProperty(':' + key); }-*/; /** * Returns the value corresponding to the key. * * @param key the key. * @return the value that corresponds to the key, or null if not present. */ public final native String get(String key) /*-{ return this[':' + key]; }-*/; /** * Puts the value in the map at the given key. The value can be null. Key- * value pairs with null value return true in has(key) and null in get(key). * * @param key the key. * @param value the value to set. */ public final native void put(String key, String value) /*-{ this[':' + key] = value; }-*/; /** * Removes the value with the given key from the map. * * @param key the key to remove. */ public final void remove(String key) { if (has(key)) { nativeRemove(key); } } /** * Modifies the current state with the delta. The delta contains key-value * pairs to modify. If the value in the delta is null the key is removed from * the current state map. * * @param delta the delta map. */ public final void applyDelta(StateMap delta) { delta.each(new Each() { @Override public void apply(String key, String value) { if (value != null) { put(key, value); } else { remove(key); } } }); } /** * Calculates the delta that can be applied to the current state map to get * the new state map. Note that null values are treated as non-existent in * both this and newMap. * * @param newMap the new map to calculate the delta for. * @return the delta that can be applied to the current state map to get the * new state map. */ public final StateMap getDelta(final StateMap newMap) { final StateMap result = StateMap.create(); newMap.each(new Each() { @Override public void apply(String key, String value) { if ((value != null) && !value.equals(get(key))) { result.put(key, value); } } }); each(new Each() { @Override public void apply(String key, String value) { if ((value != null) && (newMap.get(key) == null)) { result.put(key, null); } } }); return result; } /** * Compares key-value pairs with the otherState. This comparison is not * sensitive to the order in which the pairs are put in JS object. null * is not equal to an empty map. * * TODO(user): Consider implementing as equals; add hashCode. * * @param otherMap the map to compare to the current one. * @return true if the key-value pairs are identical, false otherwise. */ public final boolean compare(final StateMap otherMap) { return (otherMap != null) && checkKeyValue(new KeyValueComparator(otherMap)) && otherMap.checkKeyValue(new KeyValueComparator(this)); } /** * Iterates over key-value pairs and calls CheckKeyValue.check(key, value), * interrupts the loop if the returned value is false. * * @param proc Interface that implements action for each key-value pair. */ public final void each(final Each proc) { checkKeyValue(new CheckKeyValue() { @Override public boolean check(String key, String value) { proc.apply(key, value); return true; } }); } /** * Iterates over key-value pairs and calls CheckKeyValue.check(key, value), * interrupts the loop if the returned value is false. * * @param proc Interface that implements action for each key-value pair. */ public final boolean checkKeyValue(CheckKeyValue proc) { try { return checkKeyValueImpl(proc); } catch (Exception e) { GWT.getUncaughtExceptionHandler().onUncaughtException(e); return false; } } private final native boolean checkKeyValueImpl(CheckKeyValue proc) /*-{ for (var key in this) { if (this.hasOwnProperty(key) && (key.charAt(0) === ':')) { if (!proc. @org.waveprotocol.wave.client.gadget.StateMap$CheckKeyValue::check(Ljava/lang/String;Ljava/lang/String;) (key.substring(1), this[key])) { return false; } } } return true; }-*/; /** * Clears the StateMap object. */ public final void clear() { each(new Each() { @Override public void apply(String key, String value) { remove(key); } }); } /** * Copy the contents of an existing StateMap into this one. * * @param map Map to copy key-value pairs from. */ public final void copyFrom(StateMap map) { clear(); map.each(new Each() { @Override public void apply(String key, String value) { put(key, value); } }); } /** * Copy key-value pairs from jsonObject; accept only string, number, or * null values; skip all pairs with other values. This step makes sure that * the StateMap only contains sanitized keys prefixed with ':'. Note that * hasOwnProperty() cannot be used on jsonObject. However, typeof and null * comparison are safe. * * @param source JavaScriptObject to copy from. * @param target JavaScriptObject to copy into. */ public final static native void copyJson(JavaScriptObject source, JavaScriptObject target) /*-{ for (var key in target) { if (target.hasOwnProperty(key)) { delete target[key]; } } for (var key in source) { if (source[key] === null) { target[':' + key] = null; } else if (typeof source[key] === 'string') { target[':' + key] = source[key]; } else if (typeof source[key] === 'number') { target[':' + key] = String(source[key]); } } }-*/; /** * Populates this StateMap with key-value pairs from the json String. * * @param json The JSON string to process. */ public final native void fromJson(String json) /*-{ var jsonObject = {}; // Use safe eval defined in gadgets.json. This makes sure eval does not // execute any dangerous operations. if (/^[\],:{}\s]*$/.test(json.replace(/\\["\\\/b-u]/g, '@'). replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']'). replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) { jsonObject = eval('(' + json + ')'); } @org.waveprotocol.wave.client.gadget.StateMap::copyJson(Lcom/google/gwt/core/client/JavaScriptObject;Lcom/google/gwt/core/client/JavaScriptObject;) (jsonObject, this); }-*/; /** * Populates this StateMap with key-value pairs from a JavaScriptObject. * * @param jsonObject The JSON object to process. */ public final void fromJsonObject(JavaScriptObject jsonObject) { copyJson(jsonObject, this); } /** * Converts this StateMap to a JSON String. Eliminates the ":" key prefixes. * * @return a JSON string representation of this map. */ public final String toJson() { final StringBuilder builder = new StringBuilder(); builder.append("{"); each(new Each() { private boolean firstKey = true; @Override public void apply(String key, String value) { if (firstKey) { firstKey = false; } else { builder.append(","); } builder.append(escapeValue(key) + ":"); if (value == null) { builder.append("null"); } else { builder.append(escapeValue(value)); } } }); builder.append("}"); return builder.toString(); } private final native void putKeyValue(JavaScriptObject jso, String key, String value) /*-{ jso[key] = value; }-*/; /** * Convert this into a serializable JavaScriptObject. Eliminates the ":" * key prefixes. * * @return The JavaScriptObject of the valid keys. */ public final JavaScriptObject asJavaScriptObject() { final JavaScriptObject jso = JavaScriptObject.createObject(); each(new Each() { @Override public void apply(String key, String value) { putKeyValue(jso, key, value); } }); return jso; } /** * Helper JS method: removes the value with the given key from the map, the * caller makes sure the key exists. * * @param key the key to remove. */ private final native void nativeRemove(String key) /*-{ delete this[':' + key]; }-*/; /** * JavaScript string escape function. * * @param string input string. * @return escaped string. */ private final native String escapeValue(String string) /*-{ // TODO(user): Replace this with a call to // com.google.gwt.core.client.JsonUtils.escapeValue when it's checked in. var escapable = new RegExp('[\\\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4' + '\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]', 'g'); var meta = { '\b': '\\b', '\t': '\\t', '\n': '\\n', '\f': '\\f', '\r': '\\r', '"' : '\\"', '\\': '\\\\'}; escapable.lastIndex = 0; return escapable.test(string) ? '"' + string.replace(escapable, function (a) { var c = meta[a]; return typeof c === 'string' ? c : '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4); }) + '"' : '"' + string + '"'; }-*/; }