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