001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.data.validation.tests; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.io.Reader; 007import java.util.ArrayList; 008import java.util.Arrays; 009import java.util.Collections; 010import java.util.List; 011 012import javax.script.Invocable; 013import javax.script.ScriptEngine; 014import javax.script.ScriptException; 015import javax.swing.JOptionPane; 016 017import org.openstreetmap.josm.command.ChangePropertyCommand; 018import org.openstreetmap.josm.data.osm.OsmPrimitive; 019import org.openstreetmap.josm.data.validation.Severity; 020import org.openstreetmap.josm.data.validation.Test; 021import org.openstreetmap.josm.data.validation.TestError; 022import org.openstreetmap.josm.gui.Notification; 023import org.openstreetmap.josm.io.CachedFile; 024import org.openstreetmap.josm.tools.LanguageInfo; 025import org.openstreetmap.josm.tools.Logging; 026import org.openstreetmap.josm.tools.Utils; 027 028/** 029 * Tests the correct usage of the opening hour syntax of the tags 030 * {@code opening_hours}, {@code collection_times}, {@code service_times} according to 031 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a>. 032 * 033 * @since 6370 034 */ 035public class OpeningHourTest extends Test.TagTest { 036 037 /** 038 * Javascript engine 039 */ 040 public static final ScriptEngine ENGINE = Utils.getJavaScriptEngine(); 041 042 /** 043 * Constructs a new {@code OpeningHourTest}. 044 */ 045 public OpeningHourTest() { 046 super(tr("Opening hours syntax"), 047 tr("This test checks the correct usage of the opening hours syntax.")); 048 } 049 050 @Override 051 public void initialize() throws Exception { 052 super.initialize(); 053 if (ENGINE != null) { 054 try (CachedFile cf = new CachedFile("resource://data/validator/opening_hours.js"); 055 Reader reader = cf.getContentReader()) { 056 ENGINE.eval("var console={};console.debug=print;console.log=print;console.warn=print;console.error=print;"); 057 ENGINE.eval(reader); 058 ENGINE.eval("var opening_hours = require('opening_hours');"); 059 // fake country/state to not get errors on holidays 060 ENGINE.eval("var nominatimJSON = {address: {state: 'Bayern', country_code: 'de'}};"); 061 ENGINE.eval( 062 "var oh = function (value, tag_key, mode, locale) {" + 063 " try {" + 064 " var conf = {tag_key: tag_key, locale: locale};" + 065 " if (mode > -1) {" + 066 " conf.mode = mode;" + 067 " }" + 068 " var r = new opening_hours(value, nominatimJSON, conf);" + 069 " r.getErrors = function() {return [];};" + 070 " return r;" + 071 " } catch (err) {" + 072 " return {" + 073 " prettifyValue: function() {return null;}," + 074 " getWarnings: function() {return [];}," + 075 " getErrors: function() {return [err.toString()]}" + 076 " };" + 077 " }" + 078 "};"); 079 } 080 } else { 081 Logging.warn("Unable to initialize OpeningHourTest because no JavaScript engine has been found"); 082 } 083 } 084 085 /** 086 * In OSM, the syntax originally designed to describe opening hours, is now used to describe a few other things as well. 087 * Some of those other tags work with points in time instead of time ranges. 088 * To support this the mode can be specified. 089 * @since 13147 090 */ 091 public enum CheckMode { 092 /** time ranges (opening_hours, lit, …) default */ 093 TIME_RANGE(0), 094 /** points in time */ 095 POINTS_IN_TIME(1), 096 /** both (time ranges and points in time, used by collection_times, service_times, …) */ 097 BOTH(2); 098 private final int code; 099 100 CheckMode(int code) { 101 this.code = code; 102 } 103 } 104 105 /** 106 * Parses the opening hour syntax of the {@code value} given according to 107 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns an object on which 108 * methods can be called to extract information. 109 * @param value the opening hour value to be checked 110 * @param tagKey the OSM key (should be "opening_hours", "collection_times" or "service_times") 111 * @param mode whether to validate {@code value} as a time range, or points in time, or both. Can be null 112 * @param locale the locale code used for localizing messages 113 * @return The value returned by the underlying method. Usually a {@code jdk.nashorn.api.scripting.ScriptObjectMirror} 114 * @throws ScriptException if an error occurs during invocation of the underlying method 115 * @throws NoSuchMethodException if underlying method with given name or matching argument types cannot be found 116 * @since 13147 117 */ 118 public Object parse(String value, String tagKey, CheckMode mode, String locale) throws ScriptException, NoSuchMethodException { 119 return ((Invocable) ENGINE).invokeFunction("oh", value, tagKey, mode != null ? mode.code : -1, locale); 120 } 121 122 @SuppressWarnings("unchecked") 123 protected List<Object> getList(Object obj) throws ScriptException, NoSuchMethodException { 124 if (obj == null || "".equals(obj)) { 125 return Arrays.asList(); 126 } else if (obj instanceof String) { 127 final Object[] strings = ((String) obj).split("\\\\n"); 128 return Arrays.asList(strings); 129 } else if (obj instanceof List) { 130 return (List<Object>) obj; 131 } else { 132 // recursively call getList() with argument converted to newline-separated string 133 return getList(((Invocable) ENGINE).invokeMethod(obj, "join", "\\n")); 134 } 135 } 136 137 /** 138 * An error concerning invalid syntax for an "opening_hours"-like tag. 139 */ 140 public class OpeningHoursTestError { 141 private final Severity severity; 142 private final String message; 143 private final String prettifiedValue; 144 145 /** 146 * Constructs a new {@code OpeningHoursTestError} with a known pretiffied value. 147 * @param message The error message 148 * @param severity The error severity 149 * @param prettifiedValue The prettified value 150 */ 151 public OpeningHoursTestError(String message, Severity severity, String prettifiedValue) { 152 this.message = message; 153 this.severity = severity; 154 this.prettifiedValue = prettifiedValue; 155 } 156 157 /** 158 * Returns the real test error given to JOSM validator. 159 * @param p The incriminated OSM primitive. 160 * @param key The incriminated key, used for display. 161 * @return The real test error given to JOSM validator. Can be fixable or not if a prettified values has been determined. 162 */ 163 public TestError getTestError(final OsmPrimitive p, final String key) { 164 final TestError.Builder error = TestError.builder(OpeningHourTest.this, severity, 2901) 165 .message(tr("Opening hours syntax"), message) // todo obtain English message for ignore functionality 166 .primitives(p); 167 if (prettifiedValue == null || prettifiedValue.equals(p.get(key))) { 168 return error.build(); 169 } else { 170 return error.fix(() -> new ChangePropertyCommand(p, key, prettifiedValue)).build(); 171 } 172 } 173 174 /** 175 * Returns the error message. 176 * @return The error message. 177 */ 178 public String getMessage() { 179 return message; 180 } 181 182 /** 183 * Returns the prettified value. 184 * @return The prettified value. 185 */ 186 public String getPrettifiedValue() { 187 return prettifiedValue; 188 } 189 190 /** 191 * Returns the error severity. 192 * @return The error severity. 193 */ 194 public Severity getSeverity() { 195 return severity; 196 } 197 198 @Override 199 public String toString() { 200 return getMessage() + " => " + getPrettifiedValue(); 201 } 202 } 203 204 /** 205 * Checks for a correct usage of the opening hour syntax of the {@code value} given according to 206 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 207 * validation errors or an empty list. Null values result in an empty list. 208 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message 209 * @param value the opening hour value to be checked. 210 * @return a list of {@link TestError} or an empty list 211 */ 212 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value) { 213 return checkOpeningHourSyntax(key, value, null, false, LanguageInfo.getJOSMLocaleCode()); 214 } 215 216 /** 217 * Checks for a correct usage of the opening hour syntax of the {@code value} given according to 218 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 219 * validation errors or an empty list. Null values result in an empty list. 220 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). 221 * @param value the opening hour value to be checked. 222 * @param mode whether to validate {@code value} as a time range, or points in time, or both. Can be null 223 * @param ignoreOtherSeverity whether to ignore errors with {@link Severity#OTHER}. 224 * @param locale the locale code used for localizing messages 225 * @return a list of {@link TestError} or an empty list 226 */ 227 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value, CheckMode mode, 228 boolean ignoreOtherSeverity, String locale) { 229 if (ENGINE == null || value == null || value.isEmpty()) { 230 return Collections.emptyList(); 231 } 232 final List<OpeningHoursTestError> errors = new ArrayList<>(); 233 try { 234 final Object r = parse(value, key, mode, locale); 235 String prettifiedValue = null; 236 try { 237 prettifiedValue = getOpeningHoursPrettifiedValues(r); 238 } catch (ScriptException | NoSuchMethodException e) { 239 Logging.warn(e); 240 } 241 for (final Object i : getOpeningHoursErrors(r)) { 242 errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.ERROR, prettifiedValue)); 243 } 244 for (final Object i : getOpeningHoursWarnings(r)) { 245 errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.WARNING, prettifiedValue)); 246 } 247 if (!ignoreOtherSeverity && errors.isEmpty() && prettifiedValue != null && !value.equals(prettifiedValue)) { 248 errors.add(new OpeningHoursTestError(tr("opening_hours value can be prettified"), Severity.OTHER, prettifiedValue)); 249 } 250 } catch (ScriptException | NoSuchMethodException ex) { 251 Logging.error(ex); 252 new Notification(Utils.getRootCause(ex).getMessage()).setIcon(JOptionPane.ERROR_MESSAGE).show(); 253 } 254 return errors; 255 } 256 257 /** 258 * Returns the prettified value returned by the opening hours parser. 259 * @param r result of {@link #parse} 260 * @return the prettified value returned by the opening hours parser 261 * @throws NoSuchMethodException if method "prettifyValue" or matching argument types cannot be found 262 * @throws ScriptException if an error occurs during invocation of the JavaScript method 263 * @since 13296 264 */ 265 public final String getOpeningHoursPrettifiedValues(Object r) throws NoSuchMethodException, ScriptException { 266 return (String) ((Invocable) ENGINE).invokeMethod(r, "prettifyValue"); 267 } 268 269 /** 270 * Returns the list of errors returned by the opening hours parser. 271 * @param r result of {@link #parse} 272 * @return the list of errors returned by the opening hours parser 273 * @throws NoSuchMethodException if method "getErrors" or matching argument types cannot be found 274 * @throws ScriptException if an error occurs during invocation of the JavaScript method 275 * @since 13296 276 */ 277 public final List<Object> getOpeningHoursErrors(Object r) throws NoSuchMethodException, ScriptException { 278 return getList(((Invocable) ENGINE).invokeMethod(r, "getErrors")); 279 } 280 281 /** 282 * Returns the list of warnings returned by the opening hours parser. 283 * @param r result of {@link #parse} 284 * @return the list of warnings returned by the opening hours parser 285 * @throws NoSuchMethodException if method "getWarnings" or matching argument types cannot be found 286 * @throws ScriptException if an error occurs during invocation of the JavaScript method 287 * @since 13296 288 */ 289 public final List<Object> getOpeningHoursWarnings(Object r) throws NoSuchMethodException, ScriptException { 290 return getList(((Invocable) ENGINE).invokeMethod(r, "getWarnings")); 291 } 292 293 /** 294 * Translates and shortens the error/warning message. 295 * @param o error/warning message returned by {@link #getOpeningHoursErrors} or {@link #getOpeningHoursWarnings} 296 * @return translated/shortened error/warning message 297 * @since 13298 298 */ 299 public static String getErrorMessage(Object o) { 300 return o.toString().trim() 301 .replace("Unexpected token:", tr("Unexpected token:")) 302 .replace("Unexpected token (school holiday parser):", tr("Unexpected token (school holiday parser):")) 303 .replace("Unexpected token in number range:", tr("Unexpected token in number range:")) 304 .replace("Unexpected token in week range:", tr("Unexpected token in week range:")) 305 .replace("Unexpected token in weekday range:", tr("Unexpected token in weekday range:")) 306 .replace("Unexpected token in month range:", tr("Unexpected token in month range:")) 307 .replace("Unexpected token in year range:", tr("Unexpected token in year range:")) 308 .replace("This means that the syntax is not valid at that point or it is currently not supported.", tr("Invalid/unsupported syntax.")); 309 } 310 311 /** 312 * Translates and shortens the error/warning message. 313 * @param key OSM key 314 * @param o error/warning message returned by {@link #getOpeningHoursErrors} or {@link #getOpeningHoursWarnings} 315 * @return translated/shortened error/warning message 316 */ 317 static String getErrorMessage(String key, Object o) { 318 return key + " - " + getErrorMessage(o); 319 } 320 321 protected void check(final OsmPrimitive p, final String key) { 322 for (OpeningHoursTestError e : checkOpeningHourSyntax(key, p.get(key))) { 323 errors.add(e.getTestError(p, key)); 324 } 325 } 326 327 @Override 328 public void check(final OsmPrimitive p) { 329 if (p.isTagged()) { 330 check(p, "opening_hours"); 331 check(p, "collection_times"); 332 check(p, "service_times"); 333 } 334 } 335}