AbstractPluginOptions.java
package com.github.celldynamics.quimp.plugin;
import java.lang.reflect.Field;
import java.util.HashMap;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.github.celldynamics.quimp.filesystem.IQuimpSerialize;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
/**
* This abstract class serves basic methods for processing parameters strings specified in macros.
*
* <p>The principal idea is to have separate class that would hold all parameters plugin uses. Such
* class, if derived from {@link AbstractPluginOptions} allows to produce self-representation as
* JSon that can be displayed in macro recorder as plugin parameters string. Reverse operation -
* creating instance of option class from JSon string is also supported.
*
* <p>Additional features provided by this template are removing quotes from JSon string and
* allowing parameters that contain spaces. In the first case such de-quoted string can be easily
* passed as a parameter string in macro (which is also string) without the need of escaping quotes.
* De-quoted strings can be also deserialzied. Second feature allows using {@link EscapedPath}
* annotation for fields of String type which then are automatically enclosed in specified escape
* character. This allows to use white spaces in these fields.
*
* <p>Use {@link #beforeSerialize()} and {@link #afterSerialize()} to prepare object before
* converting to options string and after converting it back.
*
* <p>Methods {@link #serialize2Macro()} and
* {@link #deserialize2Macro(String, AbstractPluginOptions)} are intended for creating parameter
* strings, which can be then displayed in Macro Recorder, and creating instance of Option object
* from such strings (specified by user in macro). Remaining two other methods:
* {@link #serialize()} and {@link #deserialize(String, AbstractPluginOptions)} stand for normal
* conversion to and from JSon (both take under account {@link EscapedPath} annotation).
*
* <p>This abstract class by default provides {@link AbstractPluginOptions#paramFile} field that
* holds path to the configuration file.
*
* <p>There are following restriction to parameter string and concrete options class:
* <ul>
* <li>Quotes are not allowed in Strings (even properly escaped)
* <li>Round brackets are not allowed in strings - they are used for escaping strings
* <li>Arrays are allowed but only those containing primitive numbers and strings
* <li>Concrete object should be cloneable, {@link #serialize()} makes <b>shallow</b> copy of
* object otherwise. <b>Implement your own clone if you use arrays or collections.</b>
* <li>If there are other objects stored in concrete implementation of this abstract class, they
* must have default constructors for GSon.
* <li>For Windows paths it is recommended to use Linux separators: e.g.
* "C:/Users/Temp/fluoreszenz-test.QCONF", otherwise double escaped backslashes are required:
* "C:\\\\Users\\\\fluoreszenz-test.QCONF"
* </ul>
*
* <p>See also com.github.celldynamics.quimp.plugin.AbstractPluginOptionsTest
*
* @author p.baniukiewicz
*/
public abstract class AbstractPluginOptions implements Cloneable, IQuimpSerialize {
/**
* The Constant logger.
*/
public static final transient Logger LOGGER =
LoggerFactory.getLogger(AbstractPluginOptions.class);
/**
* Default key used to denote options string in IJ macro recorder.
*/
public static final transient String KEY = "opts";
/**
* Maximal length of parameter string.
*/
public static final transient int MAXITER = 512;
/**
* Name and path of QCONF file.
*/
@EscapedPath
public String paramFile;
/**
* Create JSon string from this object.
*
* <p>Fields annotated by {@link EscapedPath} will be enclosed by escape characters. If
* {@link EscapedPath} is applied to non string data type, it is ignored.
*
* <p>This method return regular JSon (but with escaped Strings if annotation is set).
* Complementary method {@link #serialize2Macro()} return JSon file without quotes supposed to be
* used as parameter string in macro.
*
* @return JSon string from this object
* @see EscapedPath
* @see #serialize2Macro()
*/
public String serialize() {
GsonBuilder gsonBuilder = new GsonBuilder();
Gson gson = gsonBuilder.create();
String json = null;
Object cp = null;
try {
cp = this.clone();
for (Field f : FieldUtils.getFieldsListWithAnnotation(this.getClass(), EscapedPath.class)) {
boolean flag = f.isAccessible();
try {
f.setAccessible(true);
String s = (String) f.get(this);
EscapedPath annotation = (EscapedPath) f.getAnnotation(EscapedPath.class);
s = annotation.left() + s + annotation.right();
Field cpf = getField(cp.getClass(), f.getName());
if (cpf != null) {
cpf.setAccessible(true);
cpf.set(cp, s);
cpf.setAccessible(flag);
}
} catch (IllegalArgumentException | IllegalAccessException | SecurityException
| ClassCastException e) {
; // ignore and process next field. This protects against non string fields annotated
} finally {
f.setAccessible(flag);
}
}
} catch (CloneNotSupportedException e1) {
LOGGER.debug(e1.getMessage(), e1);
} finally {
if (cp != null) {
((AbstractPluginOptions) cp).beforeSerialize();
}
json = gson.toJson(cp);
}
return json;
}
/**
* Serialize this class and produce JSon without spaces (except escaped strings) and without
* quotes.
*
* <p>Return is intended to show in macro recorder. Note that IJ require key to assign a parameter
* string to it. Recommended way of use this method is:
*
* <pre>
* <code>
* Recorder.setCommand("Generate mask");
* Recorder.recordOption(AbstractPluginOptions.KEY, opts.serialize2Macro());
* </code>
* </pre>
*
* @return JSon without spaces and quotes
* @see EscapedPath
* @see #escapeJsonMacro(String)
* @see #serialize()
* @see #deserialize2Macro(String, AbstractPluginOptions)
*/
public String serialize2Macro() {
return escapeJsonMacro(serialize());
}
/**
* Create AbstractPluginOptions reference from JSon. Remove escaping chars.
*
* <p>This method return object from regular JSon. All fields annotated with {@link EscapedPath}
* will have escaping characters removed. Complementary method
* {@link #deserialize2Macro(String, AbstractPluginOptions)} accept JSon file without quotes
* and it is supposed to be used as processor of parameter string specified in macro.
*
* @param json JSon string produced by {@link #serialize()}
* @param t type of restored object
* @return instance of T class
* @see EscapedPath
* @see #deserialize2Macro(String, AbstractPluginOptions)
*/
@SuppressWarnings("unchecked")
public static <T extends AbstractPluginOptions> T deserialize(String json, T t) {
GsonBuilder gsonBuilder = new GsonBuilder();
Gson gson = gsonBuilder.create();
T obj = null;
obj = (T) gson.fromJson(json, t.getClass());
for (Field f : FieldUtils.getFieldsListWithAnnotation(obj.getClass(), EscapedPath.class)) {
boolean flag = f.isAccessible();
try {
f.setAccessible(true);
String s = (String) f.get(obj);
EscapedPath annotation = (EscapedPath) f.getAnnotation(EscapedPath.class);
s = StringUtils.removeStart(s, Character.toString(annotation.left()));
s = StringUtils.removeEnd(s, Character.toString(annotation.right()));
f.set(obj, s);
} catch (IllegalArgumentException | IllegalAccessException | SecurityException
| ClassCastException e) {
; // ignore and process next field. This protects against non string fields annotaed
} finally {
f.setAccessible(flag);
}
}
return obj;
}
/**
* Deserialize JSon produced by {@link #serialize2Macro()}, that is json without quotations.
*
* <p>This method accepts that input string can contain a key specified by {@value #KEY}. See
* {@link #serialize2Macro()}.
*
* @param json JSon to deserialize
* @param t type of object
* @return object produced from JSon, fields annotated with {@link EscapedPath} does not contain
* escape characters.
* @throws QuimpPluginException on deserialization error. As JSon will be produced by user in
* macro script this usually will be problem with escaping or forming proper JSon.
*/
public static <T extends AbstractPluginOptions> T deserialize2Macro(String json, T t)
throws QuimpPluginException {
T obj = null;
try {
String jsonU = unescapeJsonMacro(json);
jsonU = jsonU.replaceFirst(AbstractPluginOptions.KEY + "=", "");
obj = deserialize(jsonU, t);
((AbstractPluginOptions) obj).afterSerialize();
} catch (Exception e) {
throw new QuimpPluginException("Malformed options string (" + e.getMessage() + ")", e);
}
return obj;
}
/**
* Remove quotes and white characters from JSon file.
*
* <p>Note that none of value can contain quote. Spaces in Strings for fields annotated by
* {@link EscapedPath} are preserved.
*
* @param json file to be processed
* @return json string without spaces and quotes (except annotated fields).
*/
public String escapeJsonMacro(String json) {
String nospaces = removeSpacesMacro(json);
return nospaces.replaceAll("\\\"+", "");
}
/**
* Remove white characters from string except those enclosed in ().
*
* <p>String can not start with (. Integrity (number of opening and closing brackets) is not
* checked.
*
* <p>TODO This should accept chars set in {@link EscapedPath}. (defined in class annotation)
*
* @param param string to process
* @return processed string.
*/
public static String removeSpacesMacro(String param) {
StringBuilder sb = new StringBuilder(param.length());
boolean outRegion = true; // determine that we are outside brackets
for (int i = 0; i < param.length(); i++) {
char c = param.charAt(i);
if (outRegion == true && Character.isWhitespace(c)) {
continue;
} else {
sb.append(c);
}
if (c == '(') {
outRegion = false;
}
if (c == ')') {
outRegion = true;
}
}
return sb.toString();
}
/**
* Reverse {@link #escapeJsonMacro(String)}. Add removed quotes. Do not verify integrity of
* produced JSon.
*
* @param json JSon returned by {@link #escapeJsonMacro(String)}
* @return proper JSon string
*/
public static String unescapeJsonMacro(String json) {
String nospaces = removeSpacesMacro(json);
final char toInsert = '"';
int startIndex = 0;
int indexOfParenthesSL = 0; // square left
int indexOfParenthesSR = 0; // square right
int i = 0; // iterations counter
HashMap<String, String> map = new HashMap<>();
// remove arrays [] and replace them with placeholders, they will be processed latter
while (true) {
indexOfParenthesSL = nospaces.indexOf('[', startIndex); // find opening [
if (indexOfParenthesSL < 0) {
break; // stop if not found
} else {
startIndex = indexOfParenthesSL; // start looking for ] from position of [
indexOfParenthesSR = nospaces.indexOf(']', startIndex);
if (indexOfParenthesSR < 0) { // closing not found
break; // error in general
}
// cut text between [] inclusively
String random = Long.toHexString(Double.doubleToLongBits(Math.random())); // random placeh
map.put(random, nospaces.substring(indexOfParenthesSL, indexOfParenthesSR + 1)); // store it
nospaces = nospaces.replace(map.get(random), random); // remove from sequence
startIndex = indexOfParenthesSR - map.get(random).length() + random.length(); // next iter
}
if (i++ > MAXITER) {
throw new IllegalArgumentException("Malformed options string.");
}
}
// now nospaces does not contains [], they are replaced by alphanumeric strings
// note that those string will be pu into "" after two next blocks
startIndex = 0;
int indexOfParenthes = 0;
int indexOfComa = 0;
int indexOfColon = 0;
// detect content between : and , or { what denotes value
// if it is not numeric put it in quotes
while (true) {
indexOfColon = nospaces.indexOf(':', startIndex);
startIndex = indexOfColon + 1;
if (indexOfColon < 0) {
break;
}
// nested class, find next :
if (nospaces.charAt(indexOfColon + 1) == '{') {
continue;
}
indexOfComa = nospaces.indexOf(',', indexOfColon);
indexOfParenthes = nospaces.indexOf('}', indexOfColon);
if (indexOfComa < 0 || indexOfParenthes < indexOfComa) { // whatev first, detect end of nested
indexOfComa = indexOfParenthes;
}
String sub = nospaces.substring(indexOfColon + 1, indexOfComa);
if (!NumberUtils.isCreatable(sub)) { // only in not numeric
nospaces = new StringBuilder(nospaces).insert(indexOfColon + 1, toInsert).toString();
nospaces = new StringBuilder(nospaces).insert(indexOfComa + 1, toInsert).toString();
startIndex += sub.length(); // do not search : in string in quotes
}
if (i++ > MAXITER) {
throw new IllegalArgumentException("Malformed options string.");
}
}
// detect keys between { or , and :
startIndex = 0;
indexOfComa = 0;
indexOfColon = 0;
indexOfParenthes = 0;
i = 0;
while (true) {
indexOfComa = nospaces.indexOf(',', startIndex);
indexOfParenthes = nospaces.indexOf('{', startIndex);
if (indexOfParenthes >= 0 && indexOfParenthes < Math.abs(indexOfComa)) { // begin or nested
indexOfComa = indexOfParenthes;
if (nospaces.charAt(indexOfParenthes + 1) == '}') {
startIndex = indexOfParenthes + 1;
continue;
}
}
startIndex = indexOfComa + 1;
if (indexOfComa < 0) {
break;
}
indexOfColon = nospaces.indexOf(':', startIndex);
nospaces = new StringBuilder(nospaces).insert(indexOfComa + 1, toInsert).toString();
nospaces = new StringBuilder(nospaces).insert(indexOfColon + 1, toInsert).toString();
if (i++ > MAXITER) {
throw new IllegalArgumentException("Malformed options string.");
}
}
// process content of arrays and substitute them to string. If array was numeric do not change
// it, otherwise put every element in quotes
for (Map.Entry<String, String> entry : map.entrySet()) {
String val = entry.getValue();
val = val.substring(1, val.length() - 1); // remove []
if (val.isEmpty()) {
nospaces = nospaces.replace(toInsert + entry.getKey() + toInsert, entry.getValue());
continue;
}
String[] elements = val.split(",");
// check if first is number (assume array of primitives)
if (!NumberUtils.isCreatable(elements[0])) { // not a number - add "" to all
for (i = 0; i < elements.length; i++) {
elements[i] = toInsert + elements[i] + toInsert;
}
}
// build proper array json
String ret = "[";
for (i = 0; i < elements.length; i++) {
ret = ret.concat(elements[i]).concat(",");
}
ret = ret.substring(0, ret.length() - 1).concat("]");
entry.setValue(ret);
// now map contains proper representation of arrays as json, e.g. [0,0] or ["d","e"]
// replace placeholders from nospaces (placeholders are already quoted after previous steps)
nospaces = nospaces.replace(toInsert + entry.getKey() + toInsert, entry.getValue());
}
return nospaces;
}
/*
* (non-Javadoc)
*
* @see com.github.celldynamics.quimp.filesystem.IQuimpSerialize#beforeSerialize()
*/
@Override
public void beforeSerialize() {
}
/*
* (non-Javadoc)
*
* @see com.github.celldynamics.quimp.filesystem.IQuimpSerialize#afterSerialize()
*/
@Override
public void afterSerialize() throws Exception {
}
/**
* Return the first {@link Field} in the hierarchy for the specified name.
*
* <p>Taken from stackoverflow.com/questions/16966629
*
* @param clazz class name to search
* @param name field name
* @return field instance
*
*/
private static Field getField(Class<?> clazz, String name) {
Field field = null;
while (clazz != null && field == null) {
try {
field = clazz.getDeclaredField(name);
} catch (Exception e) {
;
}
clazz = clazz.getSuperclass();
}
return field;
}
}