/* See the NOTICE file distributed with * this work for additional information regarding copyright ownership. * Esri Inc. licenses this file to You 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 com.esri.gpt.control.georss; import com.esri.gpt.catalog.discovery.rest.RestQuery; import com.esri.gpt.catalog.search.*; import com.esri.gpt.control.georss.IFeedRecords.FieldMeta; import com.esri.gpt.control.georss.RestQueryServlet.ResponseFormat; import com.esri.gpt.framework.context.ApplicationConfiguration; import com.esri.gpt.framework.context.ApplicationContext; import com.esri.gpt.framework.context.RequestContext; import com.esri.gpt.framework.context.UrnMap; import com.esri.gpt.framework.geometry.Envelope; import com.esri.gpt.framework.isodate.IsoDateFormat; import com.esri.gpt.framework.jsf.MessageBroker; import com.esri.gpt.framework.util.Val; import java.io.IOException; import java.io.PrintWriter; import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Date; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.TreeSet; import java.util.logging.Level; import java.util.logging.Logger; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; import javax.servlet.http.HttpServletRequest; import org.json.JSONException; /** * JSON feed writer. * Writes response in JSON (or pretty JSON) format. * <p/> * Uses <i>json.writer.className</i> parameter from <b>gpt.xml</b> to create an instance. * Creates default instance if parameter is empty. * <p/> * Default implementation uses <i>json.predicate.banned</i> parameter from <b>gpt.xml</b>, * which is a regular expression used to stop printing certain fields. Note * that fields beginning with "index.sys.xml" are not printed by default; the * only way to have it printed is to specify them explicitely in <i>outFields</i> * request parameter. * @see JsonSearchEngine */ public class ExtJsonFeedWriter implements FeedWriter { /** * Logger. */ protected static final Logger LOG = Logger.getLogger(ExtJsonFeedWriter.class.getCanonicalName()); /** * tab size */ private final static int TAB_SIZE = 2; /** * ISO date format */ protected final static IsoDateFormat DF = new IsoDateFormat(); /** * parameter map */ protected Map<String,String[]> parameterMap; /** * print writer */ private PrintWriter writer; /** * original query */ protected RestQuery query; /** * flag indicating if is this a pretty formated JSON */ private boolean pretty; /** * predicates */ protected Pattern predicateBanned; /** * indentation level */ private int level = 0; /** * message broker */ protected MessageBroker messageBroker; /** * Creates instance of the feed writer. * * @param parameterMap parameter map * @param context request context * @param writer writer * @param query query * @param pretty <code>true</code> for 'pretty' JSON output * @return instance of {@link ExtJsonFeedWriter} */ public static ExtJsonFeedWriter createInstance(Map<String,String[]> parameterMap, RequestContext context, PrintWriter writer, RestQuery query, Boolean pretty) { String className = getConfigParam("json.writer.className"); if (className.isEmpty()) { return new ExtJsonFeedWriter(parameterMap, context, writer, query, pretty); } else { try { //TODO this code throws exception Class writerClass = Class.forName(className); Constructor constructor = writerClass.getConstructor(new Class[]{Map.class, RequestContext.class, PrintWriter.class, RestQuery.class, Boolean.class}); return (ExtJsonFeedWriter) constructor.newInstance(parameterMap,context, writer, query, pretty); //return new DcatJsonFeedWriter(request, context, writer, query, pretty); } catch (Exception ex) { LOG.log(Level.INFO, "Error creating JSON feed writer class: " + className + ". Using default writer instead.", ex); return new ExtJsonFeedWriter(parameterMap, context, writer, query, pretty); } } } /** * Creates instance of the feed writer. * * @param request HTTP servlet request * @param context request context * @param writer writer * @param query query * @param pretty <code>true</code> for 'pretty' JSON output * @return instance of {@link ExtJsonFeedWriter} */ public static ExtJsonFeedWriter createInstance(HttpServletRequest request, RequestContext context, PrintWriter writer, RestQuery query, Boolean pretty) { return createInstance(request.getParameterMap(), context, writer, query, pretty); } /** * Sets message broker * * @param messageBroker the messageBroker to set */ public void setMessageBroker(MessageBroker messageBroker) { this.messageBroker = messageBroker; } /** * Gets message broker * * @return the messageBroker */ public MessageBroker getMessageBroker() { return messageBroker; } @Override public void write(IFeedRecords records) { if (Val.chkBool(getRequestParam("returnCountOnly"),false)) { writeCountOnly(records.getOpenSearchProperties().getNumberOfHits()); return; } if (Val.chkBool(getRequestParam("returnIdsOnly"),false)) { writeIdsOnly(records); return; } String sTitle = messageBroker.retrieveMessage("catalog.rest.title"); String sDescription = messageBroker.retrieveMessage("catalog.rest.description"); String sCopyright = messageBroker.retrieveMessage("catalog.rest.copyright"); String sGenerator = messageBroker.retrieveMessage("catalog.rest.generator"); if (sTitle.startsWith("???")) { sTitle = ""; } if (sDescription.startsWith("???")) { sDescription = ""; } if (sCopyright.startsWith("???")) { sCopyright = ""; } if (sGenerator.startsWith("???")) { sGenerator = ""; } println("{"); levelUp(); printArg("title", sTitle, true); printArg("description", sDescription, true); printArg("copyright", sCopyright, true); printArg("updated", DF.format(new Date()), true); printArg("type", "FeatureCollection", true); if (query != null) { printArg("provider", query.getRssProviderUrl(), true); printArg("source", query.getRssSourceUrl(), true); if (!query.getRepositoryId().isEmpty()) { printArg("repositoryId", query.getRepositoryId(), true); } } OpenSearchProperties osProps = records.getOpenSearchProperties(); if (osProps != null) { Long totalResults = new Long(osProps.getNumberOfHits()); Long startIndex = new Long(osProps.getStartRecord()); Long itemsPerPage = new Long(osProps.getRecordsPerPage()); if (query != null) { if (startIndex + itemsPerPage - 1 < totalResults) { printArg("more", query.getMoreUrl(), true); } } printArg("totalResults", totalResults, true); printArg("startIndex", startIndex, true); printArg("itemsPerPage", itemsPerPage, true); } printArg("displayFieldName", sTitle, true); println("\"fieldAliases\": {"); levelUp(); List<FieldMeta> fml = new ArrayList(); for (FieldMeta fm: records.getMetaData()) { if (checkAttr(fm.getName())) { fml.add(fm); } } for (int i = 0; i < fml.size(); i++) { FieldMeta f = fml.get(i); printArg(f.getName(), f.getAlias(), i < fml.size() - 1); } levelDown(); println("},"); printArg("geometryType", "esriGeometryPolygon", true); println("\"spatialReference\": { \"wkid\": " +getOutputSpatialReference()+ " },"); println("\"fields\": ["); levelUp(); for (int i = 0; i < fml.size(); i++) { FieldMeta f = fml.get(i); printField(f, i < fml.size() - 1); } levelDown(); println("],"); printRecords(records, true); printAPI(false); levelDown(); println("}"); } /** * Creates instance of the feed. * * @param request HTTP request * @param context request context * @param writer writer to write feed * @param query query * @param pretty <code>true</code> to print pretty response */ public ExtJsonFeedWriter(HttpServletRequest request, RequestContext context, PrintWriter writer, RestQuery query, Boolean pretty) { this(request.getParameterMap(), context, writer, query, pretty); } /** * Creates instance of the feed. * * @param parameterMap parameter map * @param context request context * @param writer writer to write feed * @param query query * @param pretty <code>true</code> to print pretty response */ public ExtJsonFeedWriter(Map<String,String[]> parameterMap, RequestContext context, PrintWriter writer, RestQuery query, Boolean pretty) { this.parameterMap = parameterMap; this.writer = writer; this.query = query; this.pretty = pretty; String sPredicateBanned = getConfigParam("json.predicate.banned"); if (!sPredicateBanned.isEmpty()) { try { this.predicateBanned = Pattern.compile(sPredicateBanned); } catch (PatternSyntaxException ex) { Logger.getLogger(ExtJsonFeedWriter.class.getCanonicalName()).log(Level.INFO, "Error compiling predicate: " + sPredicateBanned, ex); } } } /** * Writes number of matching records only. * @param numberOfHits */ protected void writeCountOnly(int numberOfHits) { println("{"); levelUp(); printArg("count", numberOfHits, false); levelDown(); println("}"); } /** * Writes records ids only. * @param records list of records */ protected void writeIdsOnly(List<IFeedRecord> records) { println("{"); levelUp(); printArg("objectIdFieldName", "UUID", true); println("\"objectIds\": ["); levelUp(); for (int i=0; i<records.size(); i++) { IFeedRecord r = records.get(i); String uuid = r.getUuid(); println("\""+uuid+"\""+(i<records.size()-1? ",": "")); } levelDown(); println("]"); levelDown(); println("}"); } /** * Prints a field. * * @param f field * @param more more fields */ protected void printField(FieldMeta f, boolean more) { println("{"); levelUp(); printArg("name", f.getName(), true); printArg("type", f.getType(), true); printArg("alias", f.getAlias(), f.getLength() != null); if (f.getLength() != null) { printArg("length", f.getLength(), false); } levelDown(); println("}" + (more ? "," : "")); } /** * Prints all records. * * @param records records to print * @param more <code>true</code> if more info will be printed after that * section */ protected void printRecords(IFeedRecords records, boolean more) { try { List<Envelope> envelopes = new ArrayList<Envelope>(); for (IFeedRecord r : records) { envelopes.add(r.getEnvelope()); } String outSR = getRequestParam("outSR"); if (!outSR.isEmpty() && !"4326".equals(outSR)) { GeometryService gs = GeometryService.createDefaultInstance(); envelopes = gs.project(envelopes, outSR); } println("\"features\"" + sp() + ":" + sp() + "["); levelUp(); for (int i = 0; i < records.size(); i++) { printRecord(records.get(i), envelopes.get(i), i < records.size() - 1); } levelDown(); println("]" + (more ? "," : "")); } catch (IOException ex) { LOG.log(Level.SEVERE, "Error writing records", ex); } catch (JSONException ex) { LOG.log(Level.SEVERE, "Error writing records", ex); } } /** * Prints record. * * @param r record to print * @param env envelope * @param more <code>true</code> if more info will be printed after that * section */ protected void printRecord(IFeedRecord r, Envelope env, boolean more) { println("{"); levelUp(); printArg("type", "Feature", true); printAttributes("properties", r, true); printAttributes("attributes", r, true); if (!hasRequestParam("returnGeometry") || Val.chkBool(getRequestParam("returnGeometry"), true)) { printGeometry(env, true); } ResourceLinks links = r.getResourceLinks(); printLinks(links, true); printResources(links, false); levelDown(); println("}" + (more ? "," : "")); } /** * Prints attributes. * * @param name name * @param r record * @param more <code>true</code> if more info will be printed after that * section */ protected void printAttributes(String name, IFeedRecord r, boolean more) { println("\"" +name+ "\"" + sp() + ":" + sp() + "{"); levelUp(); Map<String, IFeedAttribute> index = r.getData(IFeedRecord.STD_COLLECTION_INDEX); List<String> indexKeys = new ArrayList<String>(index.keySet()); ArrayList<Map.Entry<String, IFeedAttribute>> entries = new ArrayList<Map.Entry<String, IFeedAttribute>>(r.getData(IFeedRecord.STD_COLLECTION_CATALOG).entrySet()); Collections.sort(entries, new Comparator<Map.Entry<String, IFeedAttribute>>() { @Override public int compare(Entry<String, IFeedAttribute> o1, Entry<String, IFeedAttribute> o2) { return o1.getKey().compareToIgnoreCase(o2.getKey()); } }); boolean before = false; if (checkAttr("objectid")) { before = printAttr(before, "objectid", r.getObjectId()); } if (checkAttr("title")) { before = printAttr(before, "title", r.getTitle()); } if (checkAttr("id")) { before = printAttr(before, "id", r.getUuid()); } if (checkAttr("fileIdentifier") && !r.getFileIdentifier().isEmpty()) { before = printAttr(before, "fileIdentifier", r.getFileIdentifier()); } if (checkAttr("updated") && r.getModfiedDate() instanceof Date) { before = printAttr(before, "updated", DF.format(r.getModfiedDate())); } if (checkAttr("contentType") && !r.getContentType().isEmpty()) { before = printAttr(before, "contentType", UrnMap.URN_ESRI_GPT + ":contentType:" + r.getContentType()); } if (checkAttr("summary")) { before = printAttr(before, "summary", r.getAbstract()); } for (int i = 0; i < indexKeys.size(); i++) { String indexKey = indexKeys.get(i); IFeedAttribute indexValue = index.get(indexKey); String attrName = IFeedRecord.STD_COLLECTION_INDEX+"." + indexKey; if (checkAttr(attrName)) { if (before) { print(false, ","); print(false, "\r\n"); } print(before, "\"" + attrName + "\"" + sp() + ":" + sp() + indexValue); before = true; } } for (int i = 0; i < entries.size(); i++) { Entry<String, IFeedAttribute> e = entries.get(i); String attrName = IFeedRecord.STD_COLLECTION_CATALOG+"." + e.getKey(); if (checkAttr(attrName)) { if (before) { print(false, ","); print(false, "\r\n"); } print(before, "\"" + attrName + "\"" + sp() + ":" + sp() + e.getValue()); before = true; } } print(false, "\r\n"); levelDown(); println("}" + (more ? "," : "")); } /** * Prints geometry. * * @param env geometry * @param more flag to indicate if there will be more arguments */ protected void printGeometry(Envelope env, boolean more) { println("\"geometry\"" + sp() + ":" + sp() + "{"); levelUp(); printArg("type", "Polygon", true); printPolygonShape("coordinates",env, true); printPolygonShape("rings",env, false); levelDown(); println("}" + (more ? "," : "")); } /** * Prints a polygon. * * @param env */ protected void printPolygonShape(String name, Envelope env, boolean more) { //printArg("type", "Polygon", true); println("\"" +name+ "\"" + sp() + ":" + sp() + "["); printPolygon(env); println("]" + (more ? "," : "")); // println("],"); // println("\"spatialReference\":{\"wkid\":" + Val.chkStr(env.getWkid(), getOutputSpatialReference()) + " }"); } /** * Prints polygon. * * @param env polygon to print */ protected void printPolygon(Envelope env) { levelUp(); println("["); levelUp(); println( coord(env.getMinX(), env.getMinY()) + "," + sp() + coord(env.getMinX(), env.getMaxY()) + "," + sp() + coord(env.getMaxX(), env.getMaxY()) + "," + sp() + coord(env.getMaxX(), env.getMinY()) + "," + sp() + coord(env.getMinX(), env.getMinY())); levelDown(); println("]"); levelDown(); } /** * Prints all links. * * @param links collection of resource links * @param more flag to indicate if there will be more arguments */ protected void printLinks(ResourceLinks links, boolean more) { RequestContext rc = RequestContext.extract(null); ResourceIdentifier ri = ResourceIdentifier.newIdentifier(rc); println("\"links\"" + sp() + ":" + sp() + "["); levelUp(); ArrayList<ResourceLink> allLinks = new ArrayList<ResourceLink>(); for (int j = 0; j < links.size(); j++) { ResourceLink rl = links.get(j); if (!rl.getTag().equals(ResourceLink.TAG_CONTENTTYPE) && !rl.getTag().equals(ResourceLink.TAG_THUMBNAIL)) { allLinks.add(links.get(j)); } } for (int i = 0; i < allLinks.size(); i++) { printLink(ri, allLinks.get(i), i < allLinks.size() - 1); } levelDown(); println("]" + (more ? "," : "")); } /** * Prints all resources. * * @param links collection of resource links * @param more flag to indicate if there will be more arguments */ protected void printResources(ResourceLinks links, boolean more) { RequestContext rc = RequestContext.extract(null); ResourceIdentifier ri = ResourceIdentifier.newIdentifier(rc); println("\"resources\"" + sp() + ":" + sp() + "["); levelUp(); ResourceLink thumbnail = links.getThumbnail(); ResourceLink icon = links.getIcon(); if (thumbnail != null) { printLink(ri, thumbnail, icon != null); } if (icon != null) { printLink(ri, icon, false); } levelDown(); println("]" + (more ? "," : "")); } /** * Prints a link. * * @param ri resource identifier * @param link resource link * @param more flag to indicate if there will be more arguments */ protected final void printLink(ResourceIdentifier ri, ResourceLink link, boolean more) { if (!link.getTag().isEmpty() && !link.getUrl().isEmpty()) { String tag = link.getTag().equals(ResourceLink.TAG_CONTENTTYPE) ? "icon" : link.getTag(); printLink(link.getUrl(), tag, link.getLabel(), guessServiceUrn(ri, link), more); } } /** * Prints link. * * @param url url * @param tag tag * @param label label * @param urn classified as urn * @param more flag to indicate if there will be more arguments */ protected void printLink(String url, String tag, String label, String urn, boolean more) { println("{"); levelUp(); printArg("href", url, true); printArg("hrefType", UrnMap.URN_ESRI_GPT + ":hrefType:" + tag, true); printArg("label", label, true); printArg("type", tag, !urn.isEmpty()); if (!urn.isEmpty()) { printArg("classifiedAs", urn, false); } levelDown(); println("}" + (more ? "," : "")); } /** * Prints bottom links. * * @param more more links */ protected void printAPI(boolean more) { //String [] alts = new String[]{"georss","atom","html","htmlfragment","json","xjson","kml","csv"}; ArrayList<ResponseFormat> alts = new ArrayList<ResponseFormat>(); for (ResponseFormat f : ResponseFormat.values()) { if (f.isApi()) { alts.add(f); } } println("\"api\": ["); if (query != null) { levelUp(); for (int i = 0; i < alts.size(); i++) { ResponseFormat alt = alts.get(i); String label = alt.name().toUpperCase(); if (alt == ResponseFormat.htmlfragment) { label = "FRAGMENT"; } if (alt == ResponseFormat.pjson) { label = "JSON"; } if (alt == ResponseFormat.xjson) { label = "JSON (Extended)"; } String url = query.getRssSourceUrl().replaceAll( "f=" + ResponseFormat.pjson + "|f=" + ResponseFormat.json + "|f=" + ResponseFormat.xjson, "f=" + (alt == ResponseFormat.json ? ResponseFormat.pjson : alt)); printLink(url, alt.name(), label, "", i < alts.size() - 1); } levelDown(); } println("]" + (more ? "," : "")); } /** * Guesses URN of resource link type. * * @param ri instance of resource identifier * @param link resource link * @return URN of resource link type */ protected String guessServiceUrn(ResourceIdentifier ri, ResourceLink link) { if (link.getTag().equals(ResourceLink.TAG_OPEN)) { return ri.guessServiceUrnFromUrl(link.getUrl()); } return ""; } /** * Prints argument. * * @param argName argument name * @param argVal argument value * @param more flag to indicate if there will be more arguments */ protected final void printArg(String argName, String argVal, boolean more) { argName = Val.chkStr(argName); argVal = Val.chkStr(argVal); if (argName.length() > 0) { println("\"" + Val.escapeStrForJson(argName) + "\"" + sp() + ":" + sp() + "\"" + Val.escapeStrForJson(argVal) + "\"" + (more ? "," : "")); } } /** * Prints argument. * * @param argName argument name * @param argVal argument value * @param more flag to indicate if there will be more arguments */ protected final void printArg(String argName, Number argVal, boolean more) { argName = Val.chkStr(argName); if (argName.length() > 0) { println("\"" + Val.escapeStrForJson(argName) + "\"" + sp() + ":" + sp() + argVal + (more ? "," : "")); } } /** * Prints attribute. * * @param before <ocde>true</code> if anything has been printed before * @param argName argument name * @param argVal argument value * @return <code>true</code> if anything has been printed */ protected final boolean printAttr(boolean before, String argName, String argVal) { argName = Val.chkStr(argName); argVal = Val.chkStr(argVal); if (checkAttr(argName) && argName.length() > 0) { if (before) { print(false, ","); print(false, "\r\n"); } print(true, "\"" + Val.escapeStrForJson(argName) + "\"" + sp() + ":" + sp() + "\"" + Val.escapeStrForJson(argVal) + "\""); before = true; } return before; } /** * Prints attribute. * * @param before <ocde>true</code> if anything has been printed before * @param argName argument name * @param argVal argument value * @return <code>true</code> if anything has been printed */ protected final boolean printAttr(boolean before, String argName, long argVal) { argName = Val.chkStr(argName); if (checkAttr(argName) && argName.length() > 0) { if (before) { print(false, ","); print(false, "\r\n"); } print(true, "\"" + Val.escapeStrForJson(argName) + "\"" + sp() + ":" + sp() + argVal); before = true; } return before; } /** * Makes coordinates. * * @param x x coordinate * @param y y coordinate * @return coordinate in JSON format */ protected String coord(double x, double y) { return "[" + String.format("%f",x) + "," + String.format("%f",y) + "]"; } /** * Checks if attribute of a given name can be printed. * * @param attrName attribute name * @return <code>true</code> if attribute can be printed */ protected boolean checkAttr(String attrName) { Set<String> outFieldsSet = buildOutFieldsSet(); // check if any xml is allowed; by default no xml is allowed, only if specified // in outFields String xmlField = IFeedRecord.STD_COLLECTION_INDEX+"."+"sys.xml"; if (attrName.startsWith(xmlField) && (outFieldsSet==null || !outFieldsSet.contains(attrName))) { return false; } // must pass banned predicate; 'banned' is a regular expression which competly // hides a field from reading - even specifying it in outFields will not let it pass if (predicateBanned != null && predicateBanned.matcher(attrName).matches()) { return false; } // check if user requested specified fields through oufFields; if specified // and the current attribute is not on that list, don't print it if (outFieldsSet!=null && !outFieldsSet.contains(attrName)) { return false; } return true; } /** * Builds collection of names of fields for output. * @return set of fields names. */ protected Set<String> buildOutFieldsSet() { Set<String> outFieldsSet = null; String outFields = getRequestParam("outFields"); if (!outFields.isEmpty() && !outFields.equals("*")) { outFieldsSet = new TreeSet<String>(Arrays.asList(outFields.split(","))); } return outFieldsSet; } /** * Gets configuration parameter. * * @param paramName parameter name * @return parameter value */ protected static String getConfigParam(String paramName) { ApplicationContext appCtx = ApplicationContext.getInstance(); ApplicationConfiguration appCfg = appCtx.getConfiguration(); return Val.chkStr(appCfg.getCatalogConfiguration().getParameters().getValue(paramName)); } /** * Gets request parameter. * * @param paramName parameter name * @return request parameter */ protected String getRequestParam(String paramName) { String[] paramValues = parameterMap.get(paramName); if (paramValues!=null) { for (String param: parameterMap.get(paramName)) { param = Val.chkStr(param); if (!param.isEmpty()) { return param; } } } return ""; } /** * Checks if parameter has even been supplied. * @param paramName parameter name * @return <code>true</code> if parameter is present (even an empty string) */ protected boolean hasRequestParam(String paramName) { String[] paramValues = parameterMap.get(paramName); return paramValues!=null && paramValues.length>0; } /** * Gets output spatial reference. * @return output spatial reference. Default: <b>4326</b>. */ protected String getOutputSpatialReference() { String outSR = getRequestParam("outSR"); if (!outSR.isEmpty()) { return outSR; } return "4326"; } /** * Increases level of indentation. */ protected final void levelUp() { level++; } /** * Decreases level of indentation. */ protected final void levelDown() { level--; } /** * Prints a single line. Depending on the 'pretty' flag, line is indented or * not. * * @param text text to print */ protected final void println(String text) { if (pretty) { printTab(); writer.println(text); } else { writer.print(text); } } /** * Prints a single line without ending it with a new line. * * @param indent <code>true</code> to indent the line * @param text text to print */ protected final void print(boolean indent, String text) { if (pretty) { if (indent) { printTab(); } writer.print(text); } else { writer.print(text); } } /** * Prints tabulator. Tabulator width depends on the indentation level. */ protected final void printTab() { for (int i = 0; i < level * TAB_SIZE; i++) { writer.print(" "); } } /** * Creates a single space. Depending on the 'pretty' flag, it's either a space * or no space at all. * * @return single space */ protected final String sp() { return pretty ? " " : ""; } }