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