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