View Javadoc
1   package com.github.celldynamics.quimp.plugin;
2   
3   import java.lang.reflect.Field;
4   import java.util.HashMap;
5   import java.util.Map;
6   
7   import org.apache.commons.lang3.StringUtils;
8   import org.apache.commons.lang3.math.NumberUtils;
9   import org.apache.commons.lang3.reflect.FieldUtils;
10  import org.slf4j.Logger;
11  import org.slf4j.LoggerFactory;
12  
13  import com.github.celldynamics.quimp.filesystem.IQuimpSerialize;
14  import com.google.gson.Gson;
15  import com.google.gson.GsonBuilder;
16  
17  /**
18   * This abstract class serves basic methods for processing parameters strings specified in macros.
19   * 
20   * <p>The principal idea is to have separate class that would hold all parameters plugin uses. Such
21   * class, if derived from {@link AbstractPluginOptions} allows to produce self-representation as
22   * JSon that can be displayed in macro recorder as plugin parameters string. Reverse operation -
23   * creating instance of option class from JSon string is also supported.
24   * 
25   * <p>Additional features provided by this template are removing quotes from JSon string and
26   * allowing parameters that contain spaces. In the first case such de-quoted string can be easily
27   * passed as a parameter string in macro (which is also string) without the need of escaping quotes.
28   * De-quoted strings can be also deserialzied. Second feature allows using {@link EscapedPath}
29   * annotation for fields of String type which then are automatically enclosed in specified escape
30   * character. This allows to use white spaces in these fields.
31   * 
32   * <p>Use {@link #beforeSerialize()} and {@link #afterSerialize()} to prepare object before
33   * converting to options string and after converting it back.
34   * 
35   * <p>Methods {@link #serialize2Macro()} and
36   * {@link #deserialize2Macro(String, AbstractPluginOptions)} are intended for creating parameter
37   * strings, which can be then displayed in Macro Recorder, and creating instance of Option object
38   * from such strings (specified by user in macro). Remaining two other methods:
39   * {@link #serialize()} and {@link #deserialize(String, AbstractPluginOptions)} stand for normal
40   * conversion to and from JSon (both take under account {@link EscapedPath} annotation).
41   * 
42   * <p>This abstract class by default provides {@link AbstractPluginOptions#paramFile} field that
43   * holds path to the configuration file.
44   * 
45   * <p>There are following restriction to parameter string and concrete options class:
46   * <ul>
47   * <li>Quotes are not allowed in Strings (even properly escaped)
48   * <li>Round brackets are not allowed in strings - they are used for escaping strings
49   * <li>Arrays are allowed but only those containing primitive numbers and strings
50   * <li>Concrete object should be cloneable, {@link #serialize()} makes <b>shallow</b> copy of
51   * object otherwise. <b>Implement your own clone if you use arrays or collections.</b>
52   * <li>If there are other objects stored in concrete implementation of this abstract class, they
53   * must have default constructors for GSon.
54   * <li>For Windows paths it is recommended to use Linux separators: e.g.
55   * "C:/Users/Temp/fluoreszenz-test.QCONF", otherwise double escaped backslashes are required:
56   * "C:\\\\Users\\\\fluoreszenz-test.QCONF"
57   * </ul>
58   * 
59   * <p>See also com.github.celldynamics.quimp.plugin.AbstractPluginOptionsTest
60   * 
61   * @author p.baniukiewicz
62   */
63  public abstract class AbstractPluginOptions implements Cloneable, IQuimpSerialize {
64    /**
65     * The Constant logger.
66     */
67    public static final transient Logger LOGGER =
68            LoggerFactory.getLogger(AbstractPluginOptions.class);
69    /**
70     * Default key used to denote options string in IJ macro recorder.
71     */
72    public static final transient String KEY = "opts";
73    /**
74     * Maximal length of parameter string.
75     */
76    public static final transient int MAXITER = 512;
77    /**
78     * Name and path of QCONF file.
79     */
80    @EscapedPath
81    public String paramFile;
82  
83    /**
84     * Create JSon string from this object.
85     * 
86     * <p>Fields annotated by {@link EscapedPath} will be enclosed by escape characters. If
87     * {@link EscapedPath} is applied to non string data type, it is ignored.
88     * 
89     * <p>This method return regular JSon (but with escaped Strings if annotation is set).
90     * Complementary method {@link #serialize2Macro()} return JSon file without quotes supposed to be
91     * used as parameter string in macro.
92     * 
93     * @return JSon string from this object
94     * @see EscapedPath
95     * @see #serialize2Macro()
96     */
97    public String serialize() {
98      GsonBuilder gsonBuilder = new GsonBuilder();
99      Gson gson = gsonBuilder.create();
100     String json = null;
101     Object cp = null;
102     try {
103       cp = this.clone();
104       for (Field f : FieldUtils.getFieldsListWithAnnotation(this.getClass(), EscapedPath.class)) {
105         boolean flag = f.isAccessible();
106         try {
107           f.setAccessible(true);
108           String s = (String) f.get(this);
109           EscapedPath../../com/github/celldynamics/quimp/plugin/EscapedPath.html#EscapedPath">EscapedPath annotation = (EscapedPath) f.getAnnotation(EscapedPath.class);
110           s = annotation.left() + s + annotation.right();
111           Field cpf = getField(cp.getClass(), f.getName());
112           if (cpf != null) {
113             cpf.setAccessible(true);
114             cpf.set(cp, s);
115             cpf.setAccessible(flag);
116           }
117         } catch (IllegalArgumentException | IllegalAccessException | SecurityException
118                 | ClassCastException e) {
119           ; // ignore and process next field. This protects against non string fields annotated
120         } finally {
121           f.setAccessible(flag);
122         }
123       }
124     } catch (CloneNotSupportedException e1) {
125       LOGGER.debug(e1.getMessage(), e1);
126     } finally {
127       if (cp != null) {
128         ((AbstractPluginOptions) cp).beforeSerialize();
129       }
130       json = gson.toJson(cp);
131     }
132     return json;
133   }
134 
135   /**
136    * Serialize this class and produce JSon without spaces (except escaped strings) and without
137    * quotes.
138    * 
139    * <p>Return is intended to show in macro recorder. Note that IJ require key to assign a parameter
140    * string to it. Recommended way of use this method is:
141    * 
142    * <pre>
143    * <code>
144    * Recorder.setCommand("Generate mask");
145    * Recorder.recordOption(AbstractPluginOptions.KEY, opts.serialize2Macro()); 
146    * </code>
147    * </pre>
148    * 
149    * @return JSon without spaces and quotes
150    * @see EscapedPath
151    * @see #escapeJsonMacro(String)
152    * @see #serialize()
153    * @see #deserialize2Macro(String, AbstractPluginOptions)
154    */
155   public String serialize2Macro() {
156     return escapeJsonMacro(serialize());
157 
158   }
159 
160   /**
161    * Create AbstractPluginOptions reference from JSon. Remove escaping chars.
162    * 
163    * <p>This method return object from regular JSon. All fields annotated with {@link EscapedPath}
164    * will have escaping characters removed. Complementary method
165    * {@link #deserialize2Macro(String, AbstractPluginOptions)} accept JSon file without quotes
166    * and it is supposed to be used as processor of parameter string specified in macro.
167    * 
168    * @param json JSon string produced by {@link #serialize()}
169    * @param t type of restored object
170    * @return instance of T class
171    * @see EscapedPath
172    * @see #deserialize2Macro(String, AbstractPluginOptions)
173    */
174   @SuppressWarnings("unchecked")
175   public static <T extends AbstractPluginOptions> T deserialize(String json, T t) {
176     GsonBuilder gsonBuilder = new GsonBuilder();
177     Gson gson = gsonBuilder.create();
178     T obj = null;
179 
180     obj = (T) gson.fromJson(json, t.getClass());
181     for (Field f : FieldUtils.getFieldsListWithAnnotation(obj.getClass(), EscapedPath.class)) {
182       boolean flag = f.isAccessible();
183       try {
184         f.setAccessible(true);
185         String s = (String) f.get(obj);
186         EscapedPath../../com/github/celldynamics/quimp/plugin/EscapedPath.html#EscapedPath">EscapedPath annotation = (EscapedPath) f.getAnnotation(EscapedPath.class);
187         s = StringUtils.removeStart(s, Character.toString(annotation.left()));
188         s = StringUtils.removeEnd(s, Character.toString(annotation.right()));
189         f.set(obj, s);
190       } catch (IllegalArgumentException | IllegalAccessException | SecurityException
191               | ClassCastException e) {
192         ; // ignore and process next field. This protects against non string fields annotaed
193       } finally {
194         f.setAccessible(flag);
195       }
196     }
197     return obj;
198   }
199 
200   /**
201    * Deserialize JSon produced by {@link #serialize2Macro()}, that is json without quotations.
202    * 
203    * <p>This method accepts that input string can contain a key specified by {@value #KEY}. See
204    * {@link #serialize2Macro()}.
205    * 
206    * @param json JSon to deserialize
207    * @param t type of object
208    * @return object produced from JSon, fields annotated with {@link EscapedPath} does not contain
209    *         escape characters.
210    * @throws QuimpPluginException on deserialization error. As JSon will be produced by user in
211    *         macro script this usually will be problem with escaping or forming proper JSon.
212    */
213   public static <T extends AbstractPluginOptions> T deserialize2Macro(String json, T t)
214           throws QuimpPluginException {
215     T obj = null;
216     try {
217       String jsonU = unescapeJsonMacro(json);
218       jsonU = jsonU.replaceFirst(AbstractPluginOptions.KEY + "=", "");
219       obj = deserialize(jsonU, t);
220       ((AbstractPluginOptions) obj).afterSerialize();
221     } catch (Exception e) {
222       throw new QuimpPluginException("Malformed options string (" + e.getMessage() + ")", e);
223     }
224 
225     return obj;
226 
227   }
228 
229   /**
230    * Remove quotes and white characters from JSon file.
231    * 
232    * <p>Note that none of value can contain quote. Spaces in Strings for fields annotated by
233    * {@link EscapedPath} are preserved.
234    * 
235    * @param json file to be processed
236    * @return json string without spaces and quotes (except annotated fields).
237    */
238   public String escapeJsonMacro(String json) {
239     String nospaces = removeSpacesMacro(json);
240     return nospaces.replaceAll("\\\"+", "");
241   }
242 
243   /**
244    * Remove white characters from string except those enclosed in ().
245    * 
246    * <p>String can not start with (. Integrity (number of opening and closing brackets) is not
247    * checked.
248    * 
249    * <p>TODO This should accept chars set in {@link EscapedPath}. (defined in class annotation)
250    * 
251    * @param param string to process
252    * @return processed string.
253    */
254   public static String removeSpacesMacro(String param) {
255     StringBuilder sb = new StringBuilder(param.length());
256     boolean outRegion = true; // determine that we are outside brackets
257     for (int i = 0; i < param.length(); i++) {
258       char c = param.charAt(i);
259       if (outRegion == true && Character.isWhitespace(c)) {
260         continue;
261       } else {
262         sb.append(c);
263       }
264       if (c == '(') {
265         outRegion = false;
266       }
267       if (c == ')') {
268         outRegion = true;
269       }
270     }
271     return sb.toString();
272 
273   }
274 
275   /**
276    * Reverse {@link #escapeJsonMacro(String)}. Add removed quotes. Do not verify integrity of
277    * produced JSon.
278    * 
279    * @param json JSon returned by {@link #escapeJsonMacro(String)}
280    * @return proper JSon string
281    */
282   public static String unescapeJsonMacro(String json) {
283     String nospaces = removeSpacesMacro(json);
284 
285     final char toInsert = '"';
286     int startIndex = 0;
287     int indexOfParenthesSL = 0; // square left
288     int indexOfParenthesSR = 0; // square right
289     int i = 0; // iterations counter
290     HashMap<String, String> map = new HashMap<>();
291     // remove arrays [] and replace them with placeholders, they will be processed latter
292     while (true) {
293       indexOfParenthesSL = nospaces.indexOf('[', startIndex); // find opening [
294       if (indexOfParenthesSL < 0) {
295         break; // stop if not found
296       } else {
297         startIndex = indexOfParenthesSL; // start looking for ] from position of [
298         indexOfParenthesSR = nospaces.indexOf(']', startIndex);
299         if (indexOfParenthesSR < 0) { // closing not found
300           break; // error in general
301         }
302         // cut text between [] inclusively
303         String random = Long.toHexString(Double.doubleToLongBits(Math.random())); // random placeh
304         map.put(random, nospaces.substring(indexOfParenthesSL, indexOfParenthesSR + 1)); // store it
305         nospaces = nospaces.replace(map.get(random), random); // remove from sequence
306         startIndex = indexOfParenthesSR - map.get(random).length() + random.length(); // next iter
307       }
308       if (i++ > MAXITER) {
309         throw new IllegalArgumentException("Malformed options string.");
310       }
311     }
312 
313     // now nospaces does not contains [], they are replaced by alphanumeric strings
314     // note that those string will be pu into "" after two next blocks
315     startIndex = 0;
316     int indexOfParenthes = 0;
317     int indexOfComa = 0;
318     int indexOfColon = 0;
319     // detect content between : and , or { what denotes value
320     // if it is not numeric put it in quotes
321     while (true) {
322       indexOfColon = nospaces.indexOf(':', startIndex);
323       startIndex = indexOfColon + 1;
324       if (indexOfColon < 0) {
325         break;
326       }
327       // nested class, find next :
328       if (nospaces.charAt(indexOfColon + 1) == '{') {
329         continue;
330       }
331       indexOfComa = nospaces.indexOf(',', indexOfColon);
332       indexOfParenthes = nospaces.indexOf('}', indexOfColon);
333       if (indexOfComa < 0 || indexOfParenthes < indexOfComa) { // whatev first, detect end of nested
334         indexOfComa = indexOfParenthes;
335       }
336       String sub = nospaces.substring(indexOfColon + 1, indexOfComa);
337       if (!NumberUtils.isCreatable(sub)) { // only in not numeric
338         nospaces = new StringBuilder(nospaces).insert(indexOfColon + 1, toInsert).toString();
339         nospaces = new StringBuilder(nospaces).insert(indexOfComa + 1, toInsert).toString();
340         startIndex += sub.length(); // do not search : in string in quotes
341       }
342       if (i++ > MAXITER) {
343         throw new IllegalArgumentException("Malformed options string.");
344       }
345     }
346     // detect keys between { or , and :
347     startIndex = 0;
348     indexOfComa = 0;
349     indexOfColon = 0;
350     indexOfParenthes = 0;
351     i = 0;
352     while (true) {
353       indexOfComa = nospaces.indexOf(',', startIndex);
354       indexOfParenthes = nospaces.indexOf('{', startIndex);
355       if (indexOfParenthes >= 0 && indexOfParenthes < Math.abs(indexOfComa)) { // begin or nested
356         indexOfComa = indexOfParenthes;
357         if (nospaces.charAt(indexOfParenthes + 1) == '}') {
358           startIndex = indexOfParenthes + 1;
359           continue;
360         }
361       }
362       startIndex = indexOfComa + 1;
363       if (indexOfComa < 0) {
364         break;
365       }
366       indexOfColon = nospaces.indexOf(':', startIndex);
367       nospaces = new StringBuilder(nospaces).insert(indexOfComa + 1, toInsert).toString();
368       nospaces = new StringBuilder(nospaces).insert(indexOfColon + 1, toInsert).toString();
369       if (i++ > MAXITER) {
370         throw new IllegalArgumentException("Malformed options string.");
371       }
372     }
373 
374     // process content of arrays and substitute them to string. If array was numeric do not change
375     // it, otherwise put every element in quotes
376     for (Map.Entry<String, String> entry : map.entrySet()) {
377       String val = entry.getValue();
378       val = val.substring(1, val.length() - 1); // remove []
379       if (val.isEmpty()) {
380         nospaces = nospaces.replace(toInsert + entry.getKey() + toInsert, entry.getValue());
381         continue;
382       }
383       String[] elements = val.split(",");
384       // check if first is number (assume array of primitives)
385       if (!NumberUtils.isCreatable(elements[0])) { // not a number - add "" to all
386         for (i = 0; i < elements.length; i++) {
387           elements[i] = toInsert + elements[i] + toInsert;
388         }
389       }
390       // build proper array json
391       String ret = "[";
392       for (i = 0; i < elements.length; i++) {
393         ret = ret.concat(elements[i]).concat(",");
394       }
395       ret = ret.substring(0, ret.length() - 1).concat("]");
396       entry.setValue(ret);
397       // now map contains proper representation of arrays as json, e.g. [0,0] or ["d","e"]
398       // replace placeholders from nospaces (placeholders are already quoted after previous steps)
399       nospaces = nospaces.replace(toInsert + entry.getKey() + toInsert, entry.getValue());
400     }
401 
402     return nospaces;
403 
404   }
405 
406   /*
407    * (non-Javadoc)
408    * 
409    * @see com.github.celldynamics.quimp.filesystem.IQuimpSerialize#beforeSerialize()
410    */
411   @Override
412   public void beforeSerialize() {
413   }
414 
415   /*
416    * (non-Javadoc)
417    * 
418    * @see com.github.celldynamics.quimp.filesystem.IQuimpSerialize#afterSerialize()
419    */
420   @Override
421   public void afterSerialize() throws Exception {
422   }
423 
424   /**
425    * Return the first {@link Field} in the hierarchy for the specified name.
426    * 
427    * <p>Taken from stackoverflow.com/questions/16966629
428    * 
429    * @param clazz class name to search
430    * @param name field name
431    * @return field instance
432    * 
433    */
434   private static Field getField(Class<?> clazz, String name) {
435     Field field = null;
436     while (clazz != null && field == null) {
437       try {
438         field = clazz.getDeclaredField(name);
439       } catch (Exception e) {
440         ;
441       }
442       clazz = clazz.getSuperclass();
443     }
444     return field;
445   }
446 }