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.StringReader;
007import java.util.Arrays;
008import java.util.Collection;
009import java.util.Collections;
010import java.util.List;
011import java.util.Locale;
012import java.util.Objects;
013import java.util.stream.Collectors;
014
015import javax.swing.JCheckBox;
016import javax.swing.JPanel;
017
018import org.openstreetmap.josm.command.ChangePropertyCommand;
019import org.openstreetmap.josm.data.osm.OsmPrimitive;
020import org.openstreetmap.josm.data.preferences.BooleanProperty;
021import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper;
022import org.openstreetmap.josm.data.validation.Severity;
023import org.openstreetmap.josm.data.validation.Test.TagTest;
024import org.openstreetmap.josm.data.validation.TestError;
025import org.openstreetmap.josm.tools.GBC;
026import org.openstreetmap.josm.tools.Utils;
027
028import ch.poole.openinghoursparser.OpeningHoursParseException;
029import ch.poole.openinghoursparser.OpeningHoursParser;
030import ch.poole.openinghoursparser.Rule;
031import ch.poole.openinghoursparser.Util;
032
033/**
034 * Tests the correct usage of the opening hour syntax of the tags
035 * {@code opening_hours}, {@code collection_times}, {@code service_times} according to
036 * <a href="https://github.com/simonpoole/OpeningHoursParser">OpeningHoursParser</a>.
037 *
038 * @since 6370 (using opening_hours.js), 15978 (using OpeningHoursParser)
039 */
040public class OpeningHourTest extends TagTest {
041
042    private static final Collection<String> KEYS_TO_CHECK = Arrays.asList("opening_hours", "collection_times", "service_times");
043    private static final BooleanProperty PREF_STRICT_MODE =
044            new BooleanProperty(ValidatorPrefHelper.PREFIX + "." + OpeningHourTest.class.getSimpleName() + "." + "strict", false);
045    private final JCheckBox checkboxStrictMode = new JCheckBox(tr("Enable strict mode."));
046
047    /**
048     * Constructs a new {@code OpeningHourTest}.
049     */
050    public OpeningHourTest() {
051        super(tr("Opening hours syntax"),
052                tr("This test checks the correct usage of the opening hours syntax."));
053    }
054
055    /**
056     * Returns the real test error given to JOSM validator.
057     * @param severity The error severity
058     * @param message The error message
059     * @param key The incriminated key, used for display.
060     * @param value The incriminated value, used for comparison with prettified value.
061     * @param prettifiedValue The prettified value
062     * @param p The incriminated OSM primitive.
063     * @return The real test error given to JOSM validator. Can be fixable or not if a prettified values has been determined.
064     */
065    private TestError createTestError(Severity severity, String message, String key, String value, String prettifiedValue, OsmPrimitive p) {
066        final TestError.Builder error = TestError.builder(this, severity, 2901)
067                .message(tr("Opening hours syntax"), message) // todo obtain English message for ignore functionality
068                .primitives(p != null ? new OsmPrimitive[] {p} : new OsmPrimitive[] {});
069        if (p == null || prettifiedValue == null || prettifiedValue.equals(value)) {
070            return error.build();
071        } else {
072            return error.fix(() -> new ChangePropertyCommand(p, key, prettifiedValue)).build();
073        }
074    }
075
076    /**
077     * Checks for a correct usage of the opening hour syntax of the {@code value} given,
078     * and returns a list containing validation errors or an empty list. Null values result in an empty list.
079     * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times"). Used in error message
080     * @param value the opening hour value to be checked.
081     * @return a list of {@link TestError} or an empty list
082     */
083    public List<TestError> checkOpeningHourSyntax(final String key, final String value) {
084        return checkOpeningHourSyntax(key, value, null, Locale.getDefault());
085    }
086
087    /**
088     * Checks for a correct usage of the opening hour syntax of the {@code value} given,
089     * and returns a list containing validation errors or an empty list. Null values result in an empty list.
090     * @param key the OSM key (should be "opening_hours", "collection_times" or "service_times").
091     * @param value the opening hour value to be checked.
092     * @param p the primitive to check/fix.
093     * @param locale the locale code used for localizing messages
094     * @return a list of {@link TestError} or an empty list
095     */
096    List<TestError> checkOpeningHourSyntax(final String key, final String value, OsmPrimitive p, Locale locale) {
097        if (Utils.isEmpty(value)) {
098            return Collections.emptyList();
099        }
100
101        ch.poole.openinghoursparser.I18n.setLocale(locale);
102        String prettifiedValue = null;
103        try {
104            final boolean strict = PREF_STRICT_MODE.get();
105            final List<Rule> rules = new OpeningHoursParser(new StringReader(value)).rules(strict);
106            prettifiedValue = Util.rulesToOpeningHoursString(rules);
107            if (!Objects.equals(value, prettifiedValue) && !strict) {
108                // parse again in strict mode for detailed message
109                new OpeningHoursParser(new StringReader(value)).rules(true);
110            }
111        } catch (OpeningHoursParseException e) {
112            String message = e.getExceptions().stream()
113                    .map(OpeningHoursParseException::getMessage)
114                    .distinct()
115                    .collect(Collectors.joining("; "));
116            return Collections.singletonList(createTestError(Severity.WARNING, message, key, value, prettifiedValue, p));
117        }
118
119        if (!includeOtherSeverityChecks() || Objects.equals(value, prettifiedValue)) {
120            return Collections.emptyList();
121        } else {
122            final String message = tr("{0} value can be prettified", key);
123            return Collections.singletonList(createTestError(Severity.OTHER, message, key, value, prettifiedValue, p));
124        }
125    }
126
127    @Override
128    public void check(final OsmPrimitive p) {
129        addErrorsForPrimitive(p, this.errors);
130    }
131
132    /**
133     * Checks the tags of the given primitive and adds validation errors to the given list.
134     * @param p The primitive to test
135     * @param errors The list to add validation errors to
136     * @since 17643
137     */
138    public void addErrorsForPrimitive(OsmPrimitive p, Collection<TestError> errors) {
139        if (p.isTagged()) {
140            for (String key : KEYS_TO_CHECK) {
141                errors.addAll(checkOpeningHourSyntax(key, p.get(key), p, Locale.getDefault()));
142            }
143            // COVID-19, a few additional values are permitted, see #19048, see https://wiki.openstreetmap.org/wiki/Key:opening_hours:covid19
144            final String keyCovid19 = "opening_hours:covid19";
145            if (p.hasTag(keyCovid19) && !p.hasTag(keyCovid19, "same", "restricted", "open", "off")) {
146                errors.addAll(checkOpeningHourSyntax(keyCovid19, p.get(keyCovid19), p, Locale.getDefault()));
147            }
148        }
149    }
150
151    @Override
152    public void addGui(JPanel testPanel) {
153        super.addGui(testPanel);
154        checkboxStrictMode.setSelected(PREF_STRICT_MODE.get());
155        testPanel.add(checkboxStrictMode, GBC.eol().insets(20, 0, 0, 0));
156    }
157
158    @Override
159    public boolean ok() {
160        super.ok();
161        PREF_STRICT_MODE.put(checkboxStrictMode.isSelected());
162        return false;
163    }
164}