001/*
002 *    GeoAPI - Java interfaces for OGC/ISO standards
003 *    http://www.geoapi.org
004 *
005 *    Copyright (C) 2011-2019 Open Geospatial Consortium, Inc.
006 *    All Rights Reserved. http://www.opengeospatial.org/ogc/legal
007 *
008 *    Permission to use, copy, and modify this software and its documentation, with
009 *    or without modification, for any purpose and without fee or royalty is hereby
010 *    granted, provided that you include the following on ALL copies of the software
011 *    and documentation or portions thereof, including modifications, that you make:
012 *
013 *    1. The full text of this NOTICE in a location viewable to users of the
014 *       redistributed or derivative work.
015 *    2. Notice of any changes or modifications to the OGC files, including the
016 *       date changes were made.
017 *
018 *    THIS SOFTWARE AND DOCUMENTATION IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE
019 *    NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
020 *    TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT
021 *    THE USE OF THE SOFTWARE OR DOCUMENTATION WILL NOT INFRINGE ANY THIRD PARTY
022 *    PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
023 *
024 *    COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR
025 *    CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENTATION.
026 *
027 *    The name and trademarks of copyright holders may NOT be used in advertising or
028 *    publicity pertaining to the software without specific, written prior permission.
029 *    Title to copyright in this software and any associated documentation will at all
030 *    times remain with copyright holders.
031 */
032package org.opengis.test.report;
033
034import java.net.URI;
035import java.io.File;
036import java.io.FileOutputStream;
037import java.io.FileNotFoundException;
038import java.io.InputStream;
039import java.io.InputStreamReader;
040import java.io.LineNumberReader;
041import java.io.OutputStream;
042import java.io.OutputStreamWriter;
043import java.io.BufferedWriter;
044import java.io.IOException;
045import java.util.Date;
046import java.util.Locale;
047import java.util.Properties;
048import java.util.NoSuchElementException;
049import java.text.SimpleDateFormat;
050
051import org.opengis.util.InternationalString;
052import org.opengis.metadata.Identifier;
053import org.opengis.metadata.citation.Citation;
054import org.opengis.metadata.citation.Contact;
055import org.opengis.metadata.citation.OnlineResource;
056import org.opengis.metadata.citation.Party;
057import org.opengis.metadata.citation.Responsibility;
058
059
060/**
061 * Base class for tools generating reports as HTML pages. The reports are based on HTML templates
062 * with a few keywords to be replaced by user-provided values. The values associated to keywords
063 * can be specified in two ways:
064 *
065 * <ul>
066 *   <li>Specified at {@linkplain #Report(Properties) construction time}.</li>
067 *   <li>Stored directly in the {@linkplain #properties} map by subclasses.</li>
068 * </ul>
069 *
070 * The set of keywords, and whether a user-provided value for a given keyword is mandatory or optional,
071 * is subclass-specific. However most subclasses expect at least the following keywords:
072 *
073 * <table class="ogc">
074 *   <caption>Report properties</caption>
075 *   <tr><th>Key</th>                    <th align="center">Remarks</th>   <th>Meaning</th></tr>
076 *   <tr><td>{@code TITLE}</td>          <td align="center">&nbsp;</td>    <td>Title of the web page to produce.</td></tr>
077 *   <tr><td>{@code DATE}</td>           <td align="center">automatic</td> <td>Date of report creation.</td></tr>
078 *   <tr><td>{@code DESCRIPTION}</td>    <td align="center">optional</td>  <td>Description to write after the introductory paragraph.</td></tr>
079 *   <tr><td>{@code OBJECTS.KIND}</td>   <td align="center">&nbsp;</td>    <td>Kind of objects listed in the page (e.g. <cite>"Operation Methods"</cite>).</td></tr>
080 *   <tr><td>{@code PRODUCT.NAME}</td>   <td align="center">&nbsp;</td>    <td>Name of the product for which the report is generated.</td></tr>
081 *   <tr><td>{@code PRODUCT.VERSION}</td><td align="center">&nbsp;</td>    <td>Version of the product for which the report is generated.</td></tr>
082 *   <tr><td>{@code PRODUCT.URL}</td>    <td align="center">&nbsp;</td>    <td>URL where more information is available about the product.</td></tr>
083 *   <tr><td>{@code JAVADOC.GEOAPI}</td> <td align="center">predefined</td><td>Base URL of GeoAPI javadoc.</td></tr>
084 *   <tr><td>{@code FILENAME}</td>       <td align="center">predefined</td><td>Name of the file to create if the {@link #write(File)} argument is a directory.</td></tr>
085 * </table>
086 *
087 * <p><b>How to use this class:</b></p>
088 * <ul>
089 *   <li>Create a {@link Properties} map with the values documented in the subclass to be
090 *       instantiated. Default values exist for many keys, but those defaults may depend
091 *       on the environment (information found in {@code META-INF/MANIFEST.MF}, <i>etc</i>).
092 *       It is safer to specify values explicitly when they are known.</li>
093 *   <li>Create a new instance of the {@code Report} subclass with the above properties map
094 *       given to the constructor.</li>
095 *   <li>All {@code Report} subclasses define at least one {@code add(…)} method for declaring
096 *       the objects to include in the HTML page. At least one object or factory needs to be
097 *       declared.</li>
098 *   <li>Invoke {@link #write(File)}.</li>
099 * </ul>
100 *
101 * @author Martin Desruisseaux (Geomatys)
102 * @version 3.1
103 *
104 * @since 3.1
105 */
106public abstract class Report {
107    /**
108     * The encoding of every reports.
109     */
110    private static final String ENCODING = "UTF-8";
111
112    /**
113     * The prefix before key names in HTML templates.
114     */
115    private static final String KEY_PREFIX = "${";
116
117    /**
118     * The suffix after key names in HTML templates.
119     */
120    private static final char KEY_SUFFIX = '}';
121
122    /**
123     * Number of spaces to add when we increase the indentation.
124     */
125    static final int INDENT = 2;
126
127    /**
128     * The timestamp at the time this class has been initialized.
129     * Will be used as the default value for {@code PRODUCT.VERSION}.
130     */
131    private static final String NOW;
132    static {
133        final SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd", Locale.CANADA);
134        NOW = format.format(new Date());
135    }
136
137    /**
138     * The values to substitute to keywords in the HTML templates. This map is initialized to a
139     * copy of the map given by the user at {@linkplain #Report(Properties) construction time},
140     * or to an empty map if the user gave a {@code null} map. Subclasses can freely add, edit
141     * or remove entries in this map.
142     *
143     * <p>The list of expected entries and their {@linkplain Properties#defaults default values}
144     * (if any) are subclass-specific. See the subclass javadoc for a list of expected values.</p>
145     */
146    protected final Properties properties;
147
148    /**
149     * The properties to use as a fallback if a key was not found in the {@link #properties} map.
150     * Subclasses defined in the {@code org.opengis.test.report} package shall put their default
151     * values here. Note that the default values are highly implementation-specific and may change
152     * in any future version. We don't document them (for now) for this reason.
153     */
154    final Properties defaultProperties;
155
156    /**
157     * The listener object to inform about the progress, or {@code null} if none.
158     */
159    ProgressListener listener;
160
161    /**
162     * Creates a new report generator using the given property values. The list of expected
163     * entries is subclass specific and shall be documented in their javadoc.
164     *
165     * @param properties  the property values, or {@code null} for the default values.
166     */
167    protected Report(final Properties properties) {
168        defaultProperties = new Properties();
169        defaultProperties.setProperty("TITLE", getClass().getSimpleName());
170        defaultProperties.setProperty("DATE", NOW);
171        defaultProperties.setProperty("DESCRIPTION", "");
172        defaultProperties.setProperty("PRODUCT.VERSION", NOW);
173        defaultProperties.setProperty("FACTORY.VERSION", NOW);
174        defaultProperties.setProperty("JAVADOC.GEOAPI", "http://www.geoapi.org/snapshot/javadoc");
175        this.properties = new Properties(defaultProperties);
176        if (properties != null) {
177            this.properties.putAll(properties);
178        }
179    }
180
181    /**
182     * Infers default values for the "{@code PRODUCT.NAME}", "{@code PRODUCT.VERSION}" and "{@code PRODUCT.URL}"
183     * {@linkplain #properties} from the given vendor. The vendor argument is typically the value obtained by a
184     * call to the {@linkplain org.opengis.util.Factory#getVendor()} method.
185     *
186     * @param prefix  the property key prefix (usually {@code "PRODUCT"}, but may also be {@code "FACTORY"}).
187     * @param vendor  the vendor, or {@code null}.
188     *
189     * @see org.opengis.util.Factory#getVendor()
190     */
191    final void setVendor(final String prefix, final Citation vendor) {
192        if (vendor != null) {
193            String title = toString(vendor.getTitle());
194            /*
195            * Search for the version number, with opportunist assignment
196            * to the 'title' variable if it was not set by the above line.
197            */
198            String version = null;
199            for (final Identifier identifier : IdentifiedObjects.nullSafe(vendor.getIdentifiers())) {
200                if (identifier == null) continue;               // Paranoiac safety.
201                if (title == null) {
202                    title = identifier.getCode();
203                }
204                if (version == null) {
205                    version = identifier.getVersion();
206                }
207                if (title != null && version != null) {
208                    break;                                      // No need to continue.
209                }
210            }
211            /*
212            * Search for a URL, with opportunist assignment to the 'title' variable
213            * if it was not set by the above lines.
214            */
215            String linkage = null;
216search:     for (final Responsibility responsibility : vendor.getCitedResponsibleParties()) {
217                if (responsibility == null) continue;                       // Paranoiac safety.
218                for (final Party party : responsibility.getParties()) {
219                    if (party == null) continue;                            // Paranoiac safety.
220                    if (title == null) {
221                        title = toString(party.getName());
222                    }
223                    for (final Contact contact : party.getContactInfo()) {
224                        if (contact == null) continue;                      // Paranoiac safety.
225                        for (final OnlineResource resource : contact.getOnlineResources()) {
226                            if (resource == null) continue;                 // Paranoiac safety.
227                            final URI uri = resource.getLinkage();
228                            if (uri != null) {
229                                linkage = uri.toString();
230                                if (title != null) {                        // This is the usual case.
231                                    break search;
232                                }
233                            }
234                        }
235                    }
236                }
237            }
238            /*
239             * If we found at least one property, set all of them together
240             * (including null values) for consistency.
241             */
242            if (title   != null) defaultProperties.setProperty(prefix + ".NAME",    title);
243            if (version != null) defaultProperties.setProperty(prefix + ".VERSION", version);
244            if (linkage != null) defaultProperties.setProperty(prefix + ".URL",     linkage);
245        }
246    }
247
248    /**
249     * Returns the value associated to the given key in the {@linkplain #properties} map.
250     * If the value for the given key contains other keys, then this method will resolve
251     * those values recursively.
252     *
253     * @param  key  the property key for which to get the value.
254     * @return the value for the given key.
255     * @throws NoSuchElementException if no value has been found for the given key.
256     */
257    final String getProperty(final String key) throws NoSuchElementException {
258        final StringBuilder buffer = new StringBuilder();
259        try {
260            writeProperty(buffer, key);
261        } catch (IOException e) {
262            // Should never happen, since we are appending to a StringBuilder.
263            throw new AssertionError(e);
264        }
265        return buffer.toString();
266    }
267
268    /**
269     * Returns the locale to use for producing messages in the reports. The locale may be
270     * used for fetching the character sequences from {@link InternationalString} objects,
271     * for converting to lower-cases or for formatting numbers.
272     *
273     * <p>The locale is fixed to {@linkplain Locale#ENGLISH English} for now, but may become
274     * modifiable in a future version.</p>
275     *
276     * @return the locale to use for formatting messages.
277     *
278     * @see InternationalString#toString(Locale)
279     * @see String#toLowerCase(Locale)
280     * @see java.text.NumberFormat#getNumberInstance(Locale)
281     */
282    public Locale getLocale() {
283        return Locale.ENGLISH;
284    }
285
286    /**
287     * Returns a string value for the given text. If the given text is an instance of {@link InternationalString},
288     * then this method fetches the string for the {@linkplain #locale current locale}.
289     */
290    final String toString(final CharSequence text) {
291        if (text == null) {
292            return null;
293        }
294        if (text instanceof InternationalString) {
295            return ((InternationalString) text).toString(getLocale());
296        }
297        return text.toString();
298    }
299
300    /**
301     * Ensures that the given {@link File} object denotes a file (not a directory).
302     * If the given argument is a directory, then the {@code "FILENAME"} property value will be added.
303     */
304    final File toFile(File destination) {
305        if (destination.isDirectory()) {
306            destination = new File(destination, properties.getProperty("FILENAME", getClass().getSimpleName() + ".html"));
307        }
308        return destination;
309    }
310
311    /**
312     * Generates the HTML report in the given file or directory.
313     * If the given argument is a directory, then the path will be completed with the {@code "FILENAME"}
314     * {@linkplain #properties} value if any, or an implementation specific default filename otherwise.
315     *
316     * <p>Note that the target directory must exist; this method does not create any new directory.</p>
317     *
318     * @param  destination  the destination file or directory.
319     *         If this file already exists, then its content will be overwritten without warning.
320     * @return the file to the HTML page generated by this report. This is usually the given
321     *         {@code destination} argument, unless the destination was a directory.
322     * @throws IOException if an error occurred while writing the report.
323     */
324    public abstract File write(final File destination) throws IOException;
325
326    /**
327     * Copies the given resource file to the given directory.
328     * This method does nothing if the destination file already exits.
329     *
330     * @param  source     the name of the resource to copy.
331     * @param  directory  the destination directory.
332     * @throws IOException if an error occurred during the copy.
333     */
334    private static void copy(final String source, final File directory) throws IOException {
335        final File file = new File(directory, source);
336        if (file.isFile() && file.length() != 0) {
337            return;
338        }
339        final InputStream in = Report.class.getResourceAsStream(source);
340        if (in == null) {
341            throw new FileNotFoundException("Resource not found: " + source);
342        }
343        try (OutputStream out = new FileOutputStream(file)) {
344            int n;
345            final byte[] buffer = new byte[1024];
346            while ((n = in.read(buffer)) >= 0) {
347                out.write(buffer, 0, n);
348            }
349        } finally {
350            in.close();
351        }
352    }
353
354    /**
355     * Copies the given resource to the given file, replacing the {@code ${FOO}} occurrences in the process.
356     * For each occurrence of a {@code ${FOO}} keyword, this method invokes the
357     * {@link #writeContent(BufferedWriter, String)} method.
358     *
359     * @param  source       the resource name, without path.
360     * @param  destination  the destination file. Will be overwritten if already presents.
361     * @throws IOException if an error occurred while reading the resource or writing the file.
362     */
363    final void filter(final String source, final File destination) throws IOException {
364        copy("geoapi-reports.css", destination.getParentFile());
365        final InputStream in = Report.class.getResourceAsStream(source);
366        if (in == null) {
367            throw new FileNotFoundException("Resource not found: " + source);
368        }
369        final BufferedWriter   writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(destination), ENCODING));
370        final LineNumberReader reader = new LineNumberReader(new InputStreamReader(in, ENCODING));
371        reader.setLineNumber(1);
372        try {
373            String line;
374            while ((line = reader.readLine()) != null) {
375                if (!writeLine(writer, line)) {
376                    throw new IOException(KEY_PREFIX + " without " + KEY_SUFFIX +
377                            " at line " + reader.getLineNumber() + ":\n" + line);
378                }
379                writer.newLine();
380            }
381        } finally {
382            writer.close();
383            reader.close();
384        }
385    }
386
387    /**
388     * Writes the given line, replacing replacing the {@code ${FOO}} occurrences in the process.
389     * For each occurrence of a {@code ${FOO}} keyword, this method invokes the
390     * {@link #writeContent(BufferedWriter, String)} method.
391     *
392     * <p>This method does not invokes {@link BufferedWriter#newLine()} after the line.
393     * This is caller responsibility to invoke {@code newLine()} is desired.</p>
394     *
395     * @param  out   the output writer.
396     * @param  line  the line to write.
397     * @return {@code true} on success, or {@code false} on malformed {@code ${FOO}} key.
398     * @throws IOException if an error occurred while writing to the file.
399     */
400    private boolean writeLine(final Appendable out, final String line) throws IOException {
401        int endOfPreviousPass = 0;
402        for (int i=line.indexOf(KEY_PREFIX); i >= 0; i=line.indexOf(KEY_PREFIX, i)) {
403            out.append(line, endOfPreviousPass, i);
404            final int stop = line.indexOf(KEY_SUFFIX, i += 2);
405            if (stop < 0) {
406                return false;
407            }
408            final String key = line.substring(i, stop).trim();
409            if (out instanceof BufferedWriter) {
410                writeContent((BufferedWriter) out, key);
411            } else {
412                writeProperty(out, key);
413            }
414            endOfPreviousPass = stop + 1;
415            i = endOfPreviousPass;
416        }
417        out.append(line, endOfPreviousPass, line.length());
418        return true;
419    }
420
421    /**
422     * Writes the property value for the given key.
423     *
424     * @param  out  the output writer.
425     * @param  key  the key to replace by a content.
426     * @throws NoSuchElementException if no value has been found for the given key.
427     * @throws IOException if an error occurred while writing the content.
428     */
429    private void writeProperty(final Appendable out, final String key) throws NoSuchElementException, IOException {
430        final String value = properties.getProperty(key);
431        if (value == null) {
432            throw new NoSuchElementException("Undefined property: " + key);
433        }
434        if (!writeLine(out, value)) {
435            throw new IOException(KEY_PREFIX + " without " + KEY_SUFFIX + " for property \"" + key + "\":\n" + value);
436        }
437    }
438
439    /**
440     * Invoked every time a {@code ${FOO}} occurrence is found. The default implementation gets
441     * the value from the {@linkplain #properties} map. Subclasses can override this method in
442     * order to compute the actual content here.
443     *
444     * <p>If the value for the given key contains other keys, then this method
445     * invokes itself recursively for resolving those values.</p>
446     *
447     * @param  out  the output writer.
448     * @param  key  the key to replace by a content.
449     * @throws NoSuchElementException if no value has been found for the given key.
450     * @throws IOException if an error occurred while writing the content.
451     */
452    void writeContent(final BufferedWriter out, final String key) throws NoSuchElementException, IOException {
453        writeProperty(out, key);
454    }
455
456    /**
457     * Writes the indentation spaces on the left margin.
458     */
459    static void writeIndentation(final Appendable out, int indentation) throws IOException {
460        while (--indentation >= 0) {
461            out.append(' ');
462        }
463    }
464
465    /**
466     * Writes the {@code class="…"} attribute values inside a HTML element.
467     *
468     * @param classes  an arbitrary number of classes in the SLD. Length can be 0, 1, 2 or more.
469     *        Any null element will be silently ignored.
470     */
471    static void writeClassAttribute(final Appendable out, final String... classes) throws IOException {
472        boolean hasClasses = false;
473        for (final String classe : classes) {
474            if (classe != null) {
475                out.append(' ');
476                if (!hasClasses) {
477                    out.append("class=\"");
478                    hasClasses = true;
479                }
480                out.append(classe);
481            }
482        }
483        if (hasClasses) {
484            out.append('"');
485        }
486    }
487
488    /**
489     * Escape {@code <} and {@code >} characters for HTML. This method is null-safe.
490     * Empty strings are replaced by {@code null} value.
491     */
492    static String escape(String text) {
493        if (text != null) {
494            text = text.replace("<", "&lt;").replace(">", "&gt;").trim();
495            if (text.isEmpty()) {
496                text = null;
497            }
498        }
499        return text;
500    }
501
502    /**
503     * Invoked when the report is making some progress. This is typically invoked from a
504     * {@code add(…)} method, since they are usually slower than {@link #write(File)}.
505     * Subclasses can override this method if they want to be notified about progress.
506     *
507     * @param position  a number ranging from 0 to {@code count}.
508     *        This is typically the number or rows created so far for the HTML table to write.
509     * @param count  the maximal expected value of {@code position}. Note that this value may change between
510     *        different invocations if the report gets a better estimation about the number of rows to be created.
511     */
512    protected void progress(final int position, final int count) {
513        final ProgressListener listener = this.listener;
514        if (listener != null) {
515            listener.progress(position, count);
516        }
517    }
518}