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.ScriptEngineManager; 015import javax.script.ScriptException; 016 017import org.openstreetmap.josm.Main; 018import org.openstreetmap.josm.command.ChangePropertyCommand; 019import org.openstreetmap.josm.data.osm.OsmPrimitive; 020import org.openstreetmap.josm.data.validation.Severity; 021import org.openstreetmap.josm.data.validation.Test; 022import org.openstreetmap.josm.data.validation.TestError; 023import org.openstreetmap.josm.io.CachedFile; 024import org.openstreetmap.josm.tools.LanguageInfo; 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 = new ScriptEngineManager().getEngineByName("JavaScript"); 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, mode, locale) {" + 060 " try {" + 061 " var r = new opening_hours(value, nominatimJSON, {mode: mode, locale: locale});" + 062 " r.getErrors = function() {return [];};" + 063 " return r;" + 064 " } catch (err) {" + 065 " return {" + 066 " prettifyValue: function() {return null;}," + 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 enum CheckMode { 079 TIME_RANGE(0), POINTS_IN_TIME(1), BOTH(2); 080 private final int code; 081 082 CheckMode(int code) { 083 this.code = code; 084 } 085 } 086 087 protected Object parse(String value, CheckMode mode, String locale) throws ScriptException, NoSuchMethodException { 088 return ((Invocable) ENGINE).invokeFunction("oh", value, mode.code, locale); 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 private final Severity severity; 111 private final String message; 112 private final String prettifiedValue; 113 114 /** 115 * Constructs a new {@code OpeningHoursTestError} with a known pretiffied value. 116 * @param message The error message 117 * @param severity The error severity 118 * @param prettifiedValue The prettified value 119 */ 120 public OpeningHoursTestError(String message, Severity severity, String prettifiedValue) { 121 this.message = message; 122 this.severity = severity; 123 this.prettifiedValue = prettifiedValue; 124 } 125 126 /** 127 * Returns the real test error given to JOSM validator. 128 * @param p The incriminated OSM primitive. 129 * @param key The incriminated key, used for display. 130 * @return The real test error given to JOSM validator. Can be fixable or not if a prettified values has been determined. 131 */ 132 public TestError getTestError(final OsmPrimitive p, final String key) { 133 final TestError.Builder error = TestError.builder(OpeningHourTest.this, severity, 2901) 134 .message(tr("Opening hours syntax"), message) // todo obtain English message for ignore functionality 135 .primitives(p); 136 if (prettifiedValue == null || prettifiedValue.equals(p.get(key))) { 137 return error.build(); 138 } else { 139 return error.fix(() -> new ChangePropertyCommand(p, key, prettifiedValue)).build(); 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 (ScriptException | NoSuchMethodException e) { 209 Main.warn(e); 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 static 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}