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.InputStreamReader; 007import java.io.Reader; 008import java.nio.charset.StandardCharsets; 009import java.util.ArrayList; 010import java.util.Arrays; 011import java.util.Collections; 012import java.util.List; 013 014import javax.script.Invocable; 015import javax.script.ScriptEngine; 016import javax.script.ScriptEngineManager; 017import javax.script.ScriptException; 018 019import org.openstreetmap.josm.Main; 020import org.openstreetmap.josm.command.ChangePropertyCommand; 021import org.openstreetmap.josm.data.osm.OsmPrimitive; 022import org.openstreetmap.josm.data.validation.FixableTestError; 023import org.openstreetmap.josm.data.validation.Severity; 024import org.openstreetmap.josm.data.validation.Test; 025import org.openstreetmap.josm.data.validation.TestError; 026import org.openstreetmap.josm.io.CachedFile; 027import org.openstreetmap.josm.tools.LanguageInfo; 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 = new ScriptEngineManager().getEngineByName("JavaScript"); 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 (Reader reader = new InputStreamReader( 056 new CachedFile("resource://data/validator/opening_hours.js").getInputStream(), StandardCharsets.UTF_8)) { 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, mode, locale) {" + 063 " try {" + 064 " var r = new opening_hours(value, nominatimJSON, {mode: mode, locale: locale});" + 065 " r.getErrors = function() {return [];};" + 066 " return r;" + 067 " } catch (err) {" + 068 " return {" + 069 " getWarnings: function() {return [];}," + 070 " getErrors: function() {return [err.toString()]}" + 071 " };" + 072 " }" + 073 "};"); 074 } 075 } else { 076 Main.warn("Unable to initialize OpeningHourTest because no JavaScript engine has been found"); 077 } 078 } 079 080 enum CheckMode { 081 TIME_RANGE(0), POINTS_IN_TIME(1), BOTH(2); 082 private final int code; 083 084 CheckMode(int code) { 085 this.code = code; 086 } 087 } 088 089 protected Object parse(String value, CheckMode mode, String locale) throws ScriptException, NoSuchMethodException { 090 return ((Invocable) ENGINE).invokeFunction("oh", value, mode.code, locale); 091 } 092 093 @SuppressWarnings("unchecked") 094 protected List<Object> getList(Object obj) throws ScriptException, NoSuchMethodException { 095 if (obj == null || "".equals(obj)) { 096 return Arrays.asList(); 097 } else if (obj instanceof String) { 098 final Object[] strings = ((String) obj).split("\\\\n"); 099 return Arrays.asList(strings); 100 } else if (obj instanceof List) { 101 return (List<Object>) obj; 102 } else { 103 // recursively call getList() with argument converted to newline-separated string 104 return getList(((Invocable) ENGINE).invokeMethod(obj, "join", "\\n")); 105 } 106 } 107 108 /** 109 * An error concerning invalid syntax for an "opening_hours"-like tag. 110 */ 111 public class OpeningHoursTestError { 112 private final Severity severity; 113 private final String message; 114 private final String prettifiedValue; 115 116 /** 117 * Constructs a new {@code OpeningHoursTestError} with a known pretiffied value. 118 * @param message The error message 119 * @param severity The error severity 120 * @param prettifiedValue The prettified value 121 */ 122 public OpeningHoursTestError(String message, Severity severity, String prettifiedValue) { 123 this.message = message; 124 this.severity = severity; 125 this.prettifiedValue = prettifiedValue; 126 } 127 128 /** 129 * Returns the real test error given to JOSM validator. 130 * @param p The incriminated OSM primitive. 131 * @param key The incriminated key, used for display. 132 * @return The real test error given to JOSM validator. Can be fixable or not if a prettified values has been determined. 133 */ 134 public TestError getTestError(final OsmPrimitive p, final String key) { 135 if (prettifiedValue == null || prettifiedValue.equals(p.get(key))) { 136 return new TestError(OpeningHourTest.this, severity, message, 2901, p); 137 } else { 138 return new FixableTestError(OpeningHourTest.this, severity, message, 2901, p, 139 new ChangePropertyCommand(p, key, prettifiedValue)); 140 } 141 } 142 143 /** 144 * Returns the error message. 145 * @return The error message. 146 */ 147 public String getMessage() { 148 return message; 149 } 150 151 /** 152 * Returns the prettified value. 153 * @return The prettified value. 154 */ 155 public String getPrettifiedValue() { 156 return prettifiedValue; 157 } 158 159 /** 160 * Returns the error severity. 161 * @return The error severity. 162 */ 163 public Severity getSeverity() { 164 return severity; 165 } 166 167 @Override 168 public String toString() { 169 return getMessage() + " => " + getPrettifiedValue(); 170 } 171 } 172 173 /** 174 * Checks for a correct usage of the opening hour syntax of the {@code value} given according to 175 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 176 * validation errors or an empty list. Null values result in an empty list. 177 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message 178 * @param value the opening hour value to be checked. 179 * @param mode whether to validate {@code value} as a time range, or points in time, or both. 180 * @return a list of {@link TestError} or an empty list 181 */ 182 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value, CheckMode mode) { 183 return checkOpeningHourSyntax(key, value, mode, false, LanguageInfo.getJOSMLocaleCode()); 184 } 185 186 /** 187 * Checks for a correct usage of the opening hour syntax of the {@code value} given according to 188 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 189 * validation errors or an empty list. Null values result in an empty list. 190 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). 191 * @param value the opening hour value to be checked. 192 * @param mode whether to validate {@code value} as a time range, or points in time, or both. 193 * @param ignoreOtherSeverity whether to ignore errors with {@link Severity#OTHER}. 194 * @param locale the locale code used for localizing messages 195 * @return a list of {@link TestError} or an empty list 196 */ 197 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value, CheckMode mode, 198 boolean ignoreOtherSeverity, String locale) { 199 if (ENGINE == null || value == null || value.trim().isEmpty()) { 200 return Collections.emptyList(); 201 } 202 final List<OpeningHoursTestError> errors = new ArrayList<>(); 203 try { 204 final Object r = parse(value, mode, locale); 205 String prettifiedValue = null; 206 try { 207 prettifiedValue = (String) ((Invocable) ENGINE).invokeMethod(r, "prettifyValue"); 208 } catch (Exception e) { 209 Main.debug(e.getMessage()); 210 } 211 for (final Object i : getList(((Invocable) ENGINE).invokeMethod(r, "getErrors"))) { 212 errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.ERROR, prettifiedValue)); 213 } 214 for (final Object i : getList(((Invocable) ENGINE).invokeMethod(r, "getWarnings"))) { 215 errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.WARNING, prettifiedValue)); 216 } 217 if (!ignoreOtherSeverity && errors.isEmpty() && prettifiedValue != null && !value.equals(prettifiedValue)) { 218 errors.add(new OpeningHoursTestError(tr("opening_hours value can be prettified"), Severity.OTHER, prettifiedValue)); 219 } 220 } catch (ScriptException | NoSuchMethodException ex) { 221 Main.error(ex); 222 } 223 return errors; 224 } 225 226 /** 227 * Translates and shortens the error/warning message. 228 * @param key OSM key 229 * @param o error/warnign message 230 * @return translated/shortened error/warnign message 231 */ 232 private String getErrorMessage(String key, Object o) { 233 String msg = o.toString().trim() 234 .replace("Unexpected token:", tr("Unexpected token:")) 235 .replace("Unexpected token (school holiday parser):", tr("Unexpected token (school holiday parser):")) 236 .replace("Unexpected token in number range:", tr("Unexpected token in number range:")) 237 .replace("Unexpected token in week range:", tr("Unexpected token in week range:")) 238 .replace("Unexpected token in weekday range:", tr("Unexpected token in weekday range:")) 239 .replace("Unexpected token in month range:", tr("Unexpected token in month range:")) 240 .replace("Unexpected token in year range:", tr("Unexpected token in year range:")) 241 .replace("This means that the syntax is not valid at that point or it is currently not supported.", tr("Invalid/unsupported syntax.")); 242 return key + " - " + msg; 243 } 244 245 /** 246 * Checks for a correct usage of the opening hour syntax of the {@code value} given, in time range mode, according to 247 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 248 * validation errors or an empty list. Null values result in an empty list. 249 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message 250 * @param value the opening hour value to be checked. 251 * @return a list of {@link TestError} or an empty list 252 */ 253 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value) { 254 return checkOpeningHourSyntax(key, value, "opening_hours".equals(key) ? CheckMode.TIME_RANGE : CheckMode.BOTH); 255 } 256 257 protected void check(final OsmPrimitive p, final String key, CheckMode mode) { 258 for (OpeningHoursTestError e : checkOpeningHourSyntax(key, p.get(key), mode)) { 259 errors.add(e.getTestError(p, key)); 260 } 261 } 262 263 @Override 264 public void check(final OsmPrimitive p) { 265 check(p, "opening_hours", CheckMode.TIME_RANGE); 266 check(p, "collection_times", CheckMode.BOTH); 267 check(p, "service_times", CheckMode.BOTH); 268 } 269}