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