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 (CachedFile cf = new CachedFile("resource://data/validator/opening_hours.js"); 056 Reader reader = new InputStreamReader(cf.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 final String messageEn = message; // todo obtain English message for ignore functionality 136 if (prettifiedValue == null || prettifiedValue.equals(p.get(key))) { 137 return new TestError(OpeningHourTest.this, severity, tr("Opening hours syntax"), message, messageEn, 2901, p); 138 } else { 139 return new FixableTestError(OpeningHourTest.this, severity, tr("Opening hours syntax"), message, messageEn, 2901, p, 140 new ChangePropertyCommand(p, key, prettifiedValue)); 141 } 142 } 143 144 /** 145 * Returns the error message. 146 * @return The error message. 147 */ 148 public String getMessage() { 149 return message; 150 } 151 152 /** 153 * Returns the prettified value. 154 * @return The prettified value. 155 */ 156 public String getPrettifiedValue() { 157 return prettifiedValue; 158 } 159 160 /** 161 * Returns the error severity. 162 * @return The error severity. 163 */ 164 public Severity getSeverity() { 165 return severity; 166 } 167 168 @Override 169 public String toString() { 170 return getMessage() + " => " + getPrettifiedValue(); 171 } 172 } 173 174 /** 175 * Checks for a correct usage of the opening hour syntax of the {@code value} given according to 176 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 177 * validation errors or an empty list. Null values result in an empty list. 178 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message 179 * @param value the opening hour value to be checked. 180 * @param mode whether to validate {@code value} as a time range, or points in time, or both. 181 * @return a list of {@link TestError} or an empty list 182 */ 183 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value, CheckMode mode) { 184 return checkOpeningHourSyntax(key, value, mode, false, LanguageInfo.getJOSMLocaleCode()); 185 } 186 187 /** 188 * Checks for a correct usage of the opening hour syntax of the {@code value} given according to 189 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 190 * validation errors or an empty list. Null values result in an empty list. 191 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). 192 * @param value the opening hour value to be checked. 193 * @param mode whether to validate {@code value} as a time range, or points in time, or both. 194 * @param ignoreOtherSeverity whether to ignore errors with {@link Severity#OTHER}. 195 * @param locale the locale code used for localizing messages 196 * @return a list of {@link TestError} or an empty list 197 */ 198 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value, CheckMode mode, 199 boolean ignoreOtherSeverity, String locale) { 200 if (ENGINE == null || value == null || value.trim().isEmpty()) { 201 return Collections.emptyList(); 202 } 203 final List<OpeningHoursTestError> errors = new ArrayList<>(); 204 try { 205 final Object r = parse(value, mode, locale); 206 String prettifiedValue = null; 207 try { 208 prettifiedValue = (String) ((Invocable) ENGINE).invokeMethod(r, "prettifyValue"); 209 } catch (ScriptException | NoSuchMethodException e) { 210 Main.warn(e); 211 } 212 for (final Object i : getList(((Invocable) ENGINE).invokeMethod(r, "getErrors"))) { 213 errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.ERROR, prettifiedValue)); 214 } 215 for (final Object i : getList(((Invocable) ENGINE).invokeMethod(r, "getWarnings"))) { 216 errors.add(new OpeningHoursTestError(getErrorMessage(key, i), Severity.WARNING, prettifiedValue)); 217 } 218 if (!ignoreOtherSeverity && errors.isEmpty() && prettifiedValue != null && !value.equals(prettifiedValue)) { 219 errors.add(new OpeningHoursTestError(tr("opening_hours value can be prettified"), Severity.OTHER, prettifiedValue)); 220 } 221 } catch (ScriptException | NoSuchMethodException ex) { 222 Main.error(ex); 223 } 224 return errors; 225 } 226 227 /** 228 * Translates and shortens the error/warning message. 229 * @param key OSM key 230 * @param o error/warnign message 231 * @return translated/shortened error/warnign message 232 */ 233 private static String getErrorMessage(String key, Object o) { 234 String msg = o.toString().trim() 235 .replace("Unexpected token:", tr("Unexpected token:")) 236 .replace("Unexpected token (school holiday parser):", tr("Unexpected token (school holiday parser):")) 237 .replace("Unexpected token in number range:", tr("Unexpected token in number range:")) 238 .replace("Unexpected token in week range:", tr("Unexpected token in week range:")) 239 .replace("Unexpected token in weekday range:", tr("Unexpected token in weekday range:")) 240 .replace("Unexpected token in month range:", tr("Unexpected token in month range:")) 241 .replace("Unexpected token in year range:", tr("Unexpected token in year range:")) 242 .replace("This means that the syntax is not valid at that point or it is currently not supported.", tr("Invalid/unsupported syntax.")); 243 return key + " - " + msg; 244 } 245 246 /** 247 * Checks for a correct usage of the opening hour syntax of the {@code value} given, in time range mode, according to 248 * <a href="https://github.com/ypid/opening_hours.js">opening_hours.js</a> and returns a list containing 249 * validation errors or an empty list. Null values result in an empty list. 250 * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message 251 * @param value the opening hour value to be checked. 252 * @return a list of {@link TestError} or an empty list 253 */ 254 public List<OpeningHoursTestError> checkOpeningHourSyntax(final String key, final String value) { 255 return checkOpeningHourSyntax(key, value, "opening_hours".equals(key) ? CheckMode.TIME_RANGE : CheckMode.BOTH); 256 } 257 258 protected void check(final OsmPrimitive p, final String key, CheckMode mode) { 259 for (OpeningHoursTestError e : checkOpeningHourSyntax(key, p.get(key), mode)) { 260 errors.add(e.getTestError(p, key)); 261 } 262 } 263 264 @Override 265 public void check(final OsmPrimitive p) { 266 check(p, "opening_hours", CheckMode.TIME_RANGE); 267 check(p, "collection_times", CheckMode.BOTH); 268 check(p, "service_times", CheckMode.BOTH); 269 } 270}