source: josm/trunk/src/org/openstreetmap/josm/data/validation/tests/TagChecker.java @ 14571

Last change on this file since 14571 was 14571, checked in by GerdP, 3 months ago

see #17055 When harmonized value is only 3 characters reduce allowed Levenshtein distance from 2 to 1.

When shop=gas is not in presets, we don't want a message like "Value 'gas' for key 'shop' looks like one of [bag, car, yes]."
just because one character of those values matches.

  • Property svn:eol-style set to native
File size: 41.8 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.data.validation.tests;
3
4import static org.openstreetmap.josm.tools.I18n.marktr;
5import static org.openstreetmap.josm.tools.I18n.tr;
6
7import java.awt.GridBagConstraints;
8import java.awt.event.ActionListener;
9import java.io.BufferedReader;
10import java.io.IOException;
11import java.util.ArrayList;
12import java.util.Arrays;
13import java.util.Collection;
14import java.util.Collections;
15import java.util.HashMap;
16import java.util.HashSet;
17import java.util.List;
18import java.util.Locale;
19import java.util.Map;
20import java.util.Map.Entry;
21import java.util.Set;
22import java.util.regex.Matcher;
23import java.util.regex.Pattern;
24import java.util.regex.PatternSyntaxException;
25
26import javax.swing.JCheckBox;
27import javax.swing.JLabel;
28import javax.swing.JPanel;
29
30import org.openstreetmap.josm.command.ChangePropertyCommand;
31import org.openstreetmap.josm.command.ChangePropertyKeyCommand;
32import org.openstreetmap.josm.command.Command;
33import org.openstreetmap.josm.command.SequenceCommand;
34import org.openstreetmap.josm.data.osm.AbstractPrimitive;
35import org.openstreetmap.josm.data.osm.OsmPrimitive;
36import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
37import org.openstreetmap.josm.data.osm.OsmUtils;
38import org.openstreetmap.josm.data.osm.Tag;
39import org.openstreetmap.josm.data.osm.Tagged;
40import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper;
41import org.openstreetmap.josm.data.validation.Severity;
42import org.openstreetmap.josm.data.validation.Test.TagTest;
43import org.openstreetmap.josm.data.validation.TestError;
44import org.openstreetmap.josm.data.validation.util.Entities;
45import org.openstreetmap.josm.gui.progress.ProgressMonitor;
46import org.openstreetmap.josm.gui.tagging.presets.TaggingPreset;
47import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetItem;
48import org.openstreetmap.josm.gui.tagging.presets.TaggingPresets;
49import org.openstreetmap.josm.gui.tagging.presets.items.Check;
50import org.openstreetmap.josm.gui.tagging.presets.items.CheckGroup;
51import org.openstreetmap.josm.gui.tagging.presets.items.KeyedItem;
52import org.openstreetmap.josm.gui.widgets.EditableList;
53import org.openstreetmap.josm.io.CachedFile;
54import org.openstreetmap.josm.spi.preferences.Config;
55import org.openstreetmap.josm.tools.GBC;
56import org.openstreetmap.josm.tools.Logging;
57import org.openstreetmap.josm.tools.MultiMap;
58import org.openstreetmap.josm.tools.Utils;
59
60/**
61 * Check for misspelled or wrong tags
62 *
63 * @author frsantos
64 * @since 3669
65 */
66public class TagChecker extends TagTest {
67
68    /** The config file of ignored tags */
69    public static final String IGNORE_FILE = "resource://data/validator/ignoretags.cfg";
70    /** The config file of dictionary words */
71    public static final String SPELL_FILE = "resource://data/validator/words.cfg";
72
73    /** Normalized keys: the key should be substituted by the value if the key was not found in presets */
74    private static final Map<String, String> harmonizedKeys = new HashMap<>();
75    /** The spell check preset values which are not stored in TaggingPresets */
76    private static volatile MultiMap<String, String> additionalPresetsValueData;
77    /** The TagChecker data */
78    private static final List<CheckerData> checkerData = new ArrayList<>();
79    private static final List<String> ignoreDataStartsWith = new ArrayList<>();
80    private static final Set<String> ignoreDataEquals = new HashSet<>();
81    private static final List<String> ignoreDataEndsWith = new ArrayList<>();
82    private static final List<Tag> ignoreDataTag = new ArrayList<>();
83
84    /** The preferences prefix */
85    protected static final String PREFIX = ValidatorPrefHelper.PREFIX + "." + TagChecker.class.getSimpleName();
86
87    /**
88     * The preference key to check values
89     */
90    public static final String PREF_CHECK_VALUES = PREFIX + ".checkValues";
91    /**
92     * The preference key to check keys
93     */
94    public static final String PREF_CHECK_KEYS = PREFIX + ".checkKeys";
95    /**
96     * The preference key to enable complex checks
97     */
98    public static final String PREF_CHECK_COMPLEX = PREFIX + ".checkComplex";
99    /**
100     * The preference key to search for fixme tags
101     */
102    public static final String PREF_CHECK_FIXMES = PREFIX + ".checkFixmes";
103
104    /**
105     * The preference key for source files
106     * @see #DEFAULT_SOURCES
107     */
108    public static final String PREF_SOURCES = PREFIX + ".source";
109
110    /**
111     * The preference key to check keys - used before upload
112     */
113    public static final String PREF_CHECK_KEYS_BEFORE_UPLOAD = PREF_CHECK_KEYS + "BeforeUpload";
114    /**
115     * The preference key to check values - used before upload
116     */
117    public static final String PREF_CHECK_VALUES_BEFORE_UPLOAD = PREF_CHECK_VALUES + "BeforeUpload";
118    /**
119     * The preference key to run complex tests - used before upload
120     */
121    public static final String PREF_CHECK_COMPLEX_BEFORE_UPLOAD = PREF_CHECK_COMPLEX + "BeforeUpload";
122    /**
123     * The preference key to search for fixmes - used before upload
124     */
125    public static final String PREF_CHECK_FIXMES_BEFORE_UPLOAD = PREF_CHECK_FIXMES + "BeforeUpload";
126
127    private static final int MAX_LEVENSHTEIN_DISTANCE = 2;
128
129    protected boolean checkKeys;
130    protected boolean checkValues;
131    protected boolean checkComplex;
132    protected boolean checkFixmes;
133
134    protected JCheckBox prefCheckKeys;
135    protected JCheckBox prefCheckValues;
136    protected JCheckBox prefCheckComplex;
137    protected JCheckBox prefCheckFixmes;
138    protected JCheckBox prefCheckPaint;
139
140    protected JCheckBox prefCheckKeysBeforeUpload;
141    protected JCheckBox prefCheckValuesBeforeUpload;
142    protected JCheckBox prefCheckComplexBeforeUpload;
143    protected JCheckBox prefCheckFixmesBeforeUpload;
144    protected JCheckBox prefCheckPaintBeforeUpload;
145
146    // CHECKSTYLE.OFF: SingleSpaceSeparator
147    protected static final int EMPTY_VALUES             = 1200;
148    protected static final int INVALID_KEY              = 1201;
149    protected static final int INVALID_VALUE            = 1202;
150    protected static final int FIXME                    = 1203;
151    protected static final int INVALID_SPACE            = 1204;
152    protected static final int INVALID_KEY_SPACE        = 1205;
153    protected static final int INVALID_HTML             = 1206; /* 1207 was PAINT */
154    protected static final int LONG_VALUE               = 1208;
155    protected static final int LONG_KEY                 = 1209;
156    protected static final int LOW_CHAR_VALUE           = 1210;
157    protected static final int LOW_CHAR_KEY             = 1211;
158    protected static final int MISSPELLED_VALUE         = 1212;
159    protected static final int MISSPELLED_KEY           = 1213;
160    protected static final int MULTIPLE_SPACES          = 1214;
161    protected static final int MISSPELLED_VALUE_NO_FIX  = 1215;
162    // CHECKSTYLE.ON: SingleSpaceSeparator
163    // 1250 and up is used by tagcheck
164
165    protected EditableList sourcesList;
166
167    private static final List<String> DEFAULT_SOURCES = Arrays.asList(/*DATA_FILE, */IGNORE_FILE, SPELL_FILE);
168
169    /**
170     * Constructor
171     */
172    public TagChecker() {
173        super(tr("Tag checker"), tr("This test checks for errors in tag keys and values."));
174    }
175
176    @Override
177    public void initialize() throws IOException {
178        initializeData();
179        initializePresets();
180    }
181
182    /**
183     * Reads the spellcheck file into a HashMap.
184     * The data file is a list of words, beginning with +/-. If it starts with +,
185     * the word is valid, but if it starts with -, the word should be replaced
186     * by the nearest + word before this.
187     *
188     * @throws IOException if any I/O error occurs
189     */
190    private static void initializeData() throws IOException {
191        checkerData.clear();
192        ignoreDataStartsWith.clear();
193        ignoreDataEquals.clear();
194        ignoreDataEndsWith.clear();
195        ignoreDataTag.clear();
196        harmonizedKeys.clear();
197
198        StringBuilder errorSources = new StringBuilder();
199        for (String source : Config.getPref().getList(PREF_SOURCES, DEFAULT_SOURCES)) {
200            try (
201                CachedFile cf = new CachedFile(source);
202                BufferedReader reader = cf.getContentReader()
203            ) {
204                String okValue = null;
205                boolean tagcheckerfile = false;
206                boolean ignorefile = false;
207                boolean isFirstLine = true;
208                String line;
209                while ((line = reader.readLine()) != null && (tagcheckerfile || !line.isEmpty())) {
210                    if (line.startsWith("#")) {
211                        if (line.startsWith("# JOSM TagChecker")) {
212                            tagcheckerfile = true;
213                            if (!DEFAULT_SOURCES.contains(source)) {
214                                Logging.info(tr("Adding {0} to tag checker", source));
215                            }
216                        } else
217                        if (line.startsWith("# JOSM IgnoreTags")) {
218                            ignorefile = true;
219                            if (!DEFAULT_SOURCES.contains(source)) {
220                                Logging.info(tr("Adding {0} to ignore tags", source));
221                            }
222                        }
223                    } else if (ignorefile) {
224                        line = line.trim();
225                        if (line.length() < 4) {
226                            continue;
227                        }
228
229                        String key = line.substring(0, 2);
230                        line = line.substring(2);
231
232                        switch (key) {
233                        case "S:":
234                            ignoreDataStartsWith.add(line);
235                            break;
236                        case "E:":
237                            ignoreDataEquals.add(line);
238                            break;
239                        case "F:":
240                            ignoreDataEndsWith.add(line);
241                            break;
242                        case "K:":
243                            ignoreDataTag.add(Tag.ofString(line));
244                            break;
245                        default:
246                            if (!key.startsWith(";")) {
247                                Logging.warn("Unsupported TagChecker key: " + key);
248                            }
249                        }
250                    } else if (tagcheckerfile) {
251                        if (!line.isEmpty()) {
252                            CheckerData d = new CheckerData();
253                            String err = d.getData(line);
254
255                            if (err == null) {
256                                checkerData.add(d);
257                            } else {
258                                Logging.error(tr("Invalid tagchecker line - {0}: {1}", err, line));
259                            }
260                        }
261                    } else if (line.charAt(0) == '+') {
262                        okValue = line.substring(1);
263                    } else if (line.charAt(0) == '-' && okValue != null) {
264                        String hk = harmonizeKey(line.substring(1));
265                        if (!okValue.equals(hk)) {
266                            if (harmonizedKeys.put(hk, okValue) != null) {
267                                Logging.debug(tr("Line was ignored: {0}", line));
268                            }
269                        }
270                    } else {
271                        Logging.error(tr("Invalid spellcheck line: {0}", line));
272                    }
273                    if (isFirstLine) {
274                        isFirstLine = false;
275                        if (!(tagcheckerfile || ignorefile) && !DEFAULT_SOURCES.contains(source)) {
276                            Logging.info(tr("Adding {0} to spellchecker", source));
277                        }
278                    }
279                }
280            } catch (IOException e) {
281                Logging.error(e);
282                errorSources.append(source).append('\n');
283            }
284        }
285
286        if (errorSources.length() > 0)
287            throw new IOException(tr("Could not access data file(s):\n{0}", errorSources));
288    }
289
290    /**
291     * Reads the presets data.
292     *
293     */
294    public static void initializePresets() {
295
296        if (!Config.getPref().getBoolean(PREF_CHECK_VALUES, true))
297            return;
298
299        Collection<TaggingPreset> presets = TaggingPresets.getTaggingPresets();
300        if (!presets.isEmpty()) {
301            additionalPresetsValueData = new MultiMap<>();
302            for (String a : AbstractPrimitive.getUninterestingKeys()) {
303                additionalPresetsValueData.putVoid(a);
304            }
305            // TODO directionKeys are no longer in OsmPrimitive (search pattern is used instead)
306            for (String a : Config.getPref().getList(ValidatorPrefHelper.PREFIX + ".knownkeys",
307                    Arrays.asList("is_in", "int_ref", "fixme", "population"))) {
308                additionalPresetsValueData.putVoid(a);
309            }
310            for (TaggingPreset p : presets) {
311                for (TaggingPresetItem i : p.data) {
312                    if (i instanceof KeyedItem) {
313                        addPresetValue((KeyedItem) i);
314                    } else if (i instanceof CheckGroup) {
315                        for (Check c : ((CheckGroup) i).checks) {
316                            addPresetValue(c);
317                        }
318                    }
319                }
320            }
321        }
322    }
323
324    private static void addPresetValue(KeyedItem ky) {
325        if (ky.key != null && ky.getValues() != null) {
326            String hk = harmonizeKey(ky.key);
327            if (!ky.key.equals(hk)) {
328                harmonizedKeys.put(hk, ky.key);
329            }
330        }
331    }
332
333    /**
334     * Checks given string (key or value) if it contains characters with code below 0x20 (either newline or some other special characters)
335     * @param s string to check
336     * @return {@code true} if {@code s} contains characters with code below 0x20
337     */
338    private static boolean containsLow(String s) {
339        if (s == null)
340            return false;
341        for (int i = 0; i < s.length(); i++) {
342            if (s.charAt(i) < 0x20)
343                return true;
344        }
345        return false;
346    }
347
348    private static Set<String> getPresetValues(String key) {
349        Set<String> res = TaggingPresets.getPresetValues(key);
350        if (res != null)
351            return res;
352        return additionalPresetsValueData.get(key);
353    }
354
355    /**
356     * Determines if the given key is in internal presets.
357     * @param key key
358     * @return {@code true} if the given key is in internal presets
359     * @since 9023
360     */
361    public static boolean isKeyInPresets(String key) {
362        return getPresetValues(key) != null;
363    }
364
365    /**
366     * Determines if the given tag is in internal presets.
367     * @param key key
368     * @param value value
369     * @return {@code true} if the given tag is in internal presets
370     * @since 9023
371     */
372    public static boolean isTagInPresets(String key, String value) {
373        final Set<String> values = getPresetValues(key);
374        return values != null && (values.isEmpty() || values.contains(value));
375    }
376
377    /**
378     * Returns the list of ignored tags.
379     * @return the list of ignored tags
380     * @since 9023
381     */
382    public static List<Tag> getIgnoredTags() {
383        return new ArrayList<>(ignoreDataTag);
384    }
385
386    /**
387     * Determines if the given tag is ignored for checks "key/tag not in presets".
388     * @param key key
389     * @param value value
390     * @return {@code true} if the given tag is ignored
391     * @since 9023
392     */
393    public static boolean isTagIgnored(String key, String value) {
394        if (ignoreDataEquals.contains(key)) {
395            return true;
396        }
397        for (String a : ignoreDataStartsWith) {
398            if (key.startsWith(a)) {
399                return true;
400            }
401        }
402        for (String a : ignoreDataEndsWith) {
403            if (key.endsWith(a)) {
404                return true;
405            }
406        }
407
408        if (!isTagInPresets(key, value)) {
409            for (Tag a : ignoreDataTag) {
410                if (key.equals(a.getKey()) && value.equals(a.getValue())) {
411                    return true;
412                }
413            }
414        }
415        return false;
416    }
417
418    /**
419     * Checks the primitive tags
420     * @param p The primitive to check
421     */
422    @Override
423    public void check(OsmPrimitive p) {
424        if (!p.isTagged())
425            return;
426
427        // Just a collection to know if a primitive has been already marked with error
428        MultiMap<OsmPrimitive, String> withErrors = new MultiMap<>();
429
430        if (checkComplex) {
431            Map<String, String> keys = p.getKeys();
432            for (CheckerData d : checkerData) {
433                if (d.match(p, keys)) {
434                    errors.add(TestError.builder(this, d.getSeverity(), d.getCode())
435                            .message(tr("Suspicious tag/value combinations"), d.getDescription())
436                            .primitives(p)
437                            .build());
438                    withErrors.put(p, "TC");
439                }
440            }
441        }
442
443        for (Entry<String, String> prop : p.getKeys().entrySet()) {
444            String s = marktr("Tag ''{0}'' invalid.");
445            String key = prop.getKey();
446            String value = prop.getValue();
447            if (checkValues && (containsLow(value)) && !withErrors.contains(p, "ICV")) {
448                errors.add(TestError.builder(this, Severity.WARNING, LOW_CHAR_VALUE)
449                        .message(tr("Tag value contains character with code less than 0x20"), s, key)
450                        .primitives(p)
451                        .build());
452                withErrors.put(p, "ICV");
453            }
454            if (checkKeys && (containsLow(key)) && !withErrors.contains(p, "ICK")) {
455                errors.add(TestError.builder(this, Severity.WARNING, LOW_CHAR_KEY)
456                        .message(tr("Tag key contains character with code less than 0x20"), s, key)
457                        .primitives(p)
458                        .build());
459                withErrors.put(p, "ICK");
460            }
461            if (checkValues && (value != null && value.length() > Tagged.MAX_TAG_LENGTH) && !withErrors.contains(p, "LV")) {
462                errors.add(TestError.builder(this, Severity.ERROR, LONG_VALUE)
463                        .message(tr("Tag value longer than {0} characters ({1} characters)", Tagged.MAX_TAG_LENGTH, value.length()), s, key)
464                        .primitives(p)
465                        .build());
466                withErrors.put(p, "LV");
467            }
468            if (checkKeys && (key != null && key.length() > Tagged.MAX_TAG_LENGTH) && !withErrors.contains(p, "LK")) {
469                errors.add(TestError.builder(this, Severity.ERROR, LONG_KEY)
470                        .message(tr("Tag key longer than {0} characters ({1} characters)", Tagged.MAX_TAG_LENGTH, key.length()), s, key)
471                        .primitives(p)
472                        .build());
473                withErrors.put(p, "LK");
474            }
475            if (checkValues && (value == null || value.trim().isEmpty()) && !withErrors.contains(p, "EV")) {
476                errors.add(TestError.builder(this, Severity.WARNING, EMPTY_VALUES)
477                        .message(tr("Tags with empty values"), s, key)
478                        .primitives(p)
479                        .build());
480                withErrors.put(p, "EV");
481            }
482            if (checkKeys && key != null && key.indexOf(' ') >= 0 && !withErrors.contains(p, "IPK")) {
483                errors.add(TestError.builder(this, Severity.WARNING, INVALID_KEY_SPACE)
484                        .message(tr("Invalid white space in property key"), s, key)
485                        .primitives(p)
486                        .build());
487                withErrors.put(p, "IPK");
488            }
489            if (checkValues && value != null && (value.startsWith(" ") || value.endsWith(" ")) && !withErrors.contains(p, "SPACE")) {
490                errors.add(TestError.builder(this, Severity.WARNING, INVALID_SPACE)
491                        .message(tr("Property values start or end with white space"), s, key)
492                        .primitives(p)
493                        .build());
494                withErrors.put(p, "SPACE");
495            }
496            if (checkValues && value != null && value.contains("  ") && !withErrors.contains(p, "SPACE")) {
497                errors.add(TestError.builder(this, Severity.WARNING, MULTIPLE_SPACES)
498                        .message(tr("Property values contain multiple white spaces"), s, key)
499                        .primitives(p)
500                        .build());
501                withErrors.put(p, "SPACE");
502            }
503            if (checkValues && value != null && !value.equals(Entities.unescape(value)) && !withErrors.contains(p, "HTML")) {
504                errors.add(TestError.builder(this, Severity.OTHER, INVALID_HTML)
505                        .message(tr("Property values contain HTML entity"), s, key)
506                        .primitives(p)
507                        .build());
508                withErrors.put(p, "HTML");
509            }
510            if (checkValues && key != null && value != null && !value.isEmpty() && additionalPresetsValueData != null
511                    && !isTagIgnored(key, value)) {
512                if (!isKeyInPresets(key)) {
513                    String prettifiedKey = harmonizeKey(key);
514                    String fixedKey = isKeyInPresets(prettifiedKey) ? prettifiedKey : harmonizedKeys.get(prettifiedKey);
515                    if (fixedKey != null && !"".equals(fixedKey) && !fixedKey.equals(key)) {
516                        // misspelled preset key
517                        final TestError.Builder error = TestError.builder(this, Severity.WARNING, MISSPELLED_KEY)
518                                .message(tr("Misspelled property key"), marktr("Key ''{0}'' looks like ''{1}''."), key, fixedKey)
519                                .primitives(p);
520                        if (p.hasKey(fixedKey)) {
521                            errors.add(error.build());
522                        } else {
523                            errors.add(error.fix(() -> new ChangePropertyKeyCommand(p, key, fixedKey)).build());
524                        }
525                        withErrors.put(p, "WPK");
526                    } else {
527                        errors.add(TestError.builder(this, Severity.OTHER, INVALID_VALUE)
528                                .message(tr("Presets do not contain property key"), marktr("Key ''{0}'' not in presets."), key)
529                                .primitives(p)
530                                .build());
531                        withErrors.put(p, "UPK");
532                    }
533                } else if (!isTagInPresets(key, value)) {
534                    // try to fix common typos and check again if value is still unknown
535                    final String harmonizedValue = harmonizeValue(prop.getValue());
536                    String fixedValue = null;
537                    Set<String> possibleValues = getPresetValues(key);
538                    List<String> fixVals = new ArrayList<>();
539                    int maxPresetValueLen = 0;
540                    if (possibleValues.contains(harmonizedValue)) {
541                        fixedValue = harmonizedValue;
542                    } else {
543                        // use Levenshtein distance to find typical typos
544                        int minDist = MAX_LEVENSHTEIN_DISTANCE + 1;
545                        String closest = null;
546                        for (String possibleVal : possibleValues) {
547                            if (possibleVal.isEmpty())
548                                continue;
549                            maxPresetValueLen = Math.max(maxPresetValueLen, possibleVal.length());
550                            if (harmonizedValue.length() < 3 && possibleVal.length() >= harmonizedValue.length() + MAX_LEVENSHTEIN_DISTANCE) {
551                                // don't suggest fix value when given value is short and lengths are too different
552                                // for example surface=u would result in surface=mud
553                                continue;
554                            }
555                            int dist = Utils.getLevenshteinDistance(possibleVal, harmonizedValue);
556                            if (dist >= harmonizedValue.length()) {
557                                // short value, all characters are different. Don't warn, might say Value '10' for key 'fee' looks like 'no'.
558                                continue;
559                            }
560                            if (dist < minDist) {
561                                closest = possibleVal;
562                                minDist = dist;
563                                fixVals.clear();
564                                fixVals.add(possibleVal);
565                            } else if (dist == minDist) {
566                                fixVals.add(possibleVal);
567                            }
568                        }
569                        if (minDist <= MAX_LEVENSHTEIN_DISTANCE && maxPresetValueLen > MAX_LEVENSHTEIN_DISTANCE
570                                && (harmonizedValue.length() > 3 || minDist < MAX_LEVENSHTEIN_DISTANCE)) {
571                            if (fixVals.size() < 2) {
572                                fixedValue = closest;
573                            } else {
574                                Collections.sort(fixVals);
575                                // misspelled preset value with multiple good alternatives
576                                errors.add(TestError.builder(this, Severity.WARNING, MISSPELLED_VALUE_NO_FIX)
577                                        .message(tr("Misspelled property value"),
578                                                marktr("Value ''{0}'' for key ''{1}'' looks like one of {2}."),
579                                                prop.getValue(), key, fixVals)
580                                        .primitives(p).build());
581                                withErrors.put(p, "WPV");
582                                continue;
583                            }
584                        }
585                    }
586                    if (fixedValue != null && possibleValues.contains(fixedValue)) {
587                        final String newValue = fixedValue;
588                        // misspelled preset value
589                        errors.add(TestError.builder(this, Severity.WARNING, MISSPELLED_VALUE)
590                                .message(tr("Misspelled property value"),
591                                        marktr("Value ''{0}'' for key ''{1}'' looks like ''{2}''."), prop.getValue(), key, newValue)
592                                .primitives(p)
593                                .fix(() -> new ChangePropertyCommand(p, key, newValue))
594                                .build());
595                        withErrors.put(p, "WPV");
596                    } else {
597                        // unknown preset value
598                        errors.add(TestError.builder(this, Severity.OTHER, INVALID_VALUE)
599                                .message(tr("Presets do not contain property value"),
600                                        marktr("Value ''{0}'' for key ''{1}'' not in presets."), prop.getValue(), key)
601                                .primitives(p)
602                                .build());
603                        withErrors.put(p, "UPV");
604                    }
605                }
606            }
607            if (checkFixmes && key != null && value != null && !value.isEmpty() && isFixme(key, value) && !withErrors.contains(p, "FIXME")) {
608               errors.add(TestError.builder(this, Severity.OTHER, FIXME)
609                .message(tr("FIXMES"))
610                .primitives(p)
611                .build());
612               withErrors.put(p, "FIXME");
613            }
614        }
615    }
616
617    private static boolean isFixme(String key, String value) {
618        return key.toLowerCase(Locale.ENGLISH).contains("fixme") || key.contains("todo")
619          || value.toLowerCase(Locale.ENGLISH).contains("fixme") || value.contains("check and delete");
620    }
621
622    private static String harmonizeKey(String key) {
623        return Utils.strip(key.toLowerCase(Locale.ENGLISH).replace('-', '_').replace(':', '_').replace(' ', '_'), "-_;:,");
624    }
625
626    private static String harmonizeValue(String value) {
627        return Utils.strip(value.toLowerCase(Locale.ENGLISH).replace('-', '_').replace(' ', '_'), "-_;:,");
628    }
629
630    @Override
631    public void startTest(ProgressMonitor monitor) {
632        super.startTest(monitor);
633        checkKeys = Config.getPref().getBoolean(PREF_CHECK_KEYS, true);
634        if (isBeforeUpload) {
635            checkKeys = checkKeys && Config.getPref().getBoolean(PREF_CHECK_KEYS_BEFORE_UPLOAD, true);
636        }
637
638        checkValues = Config.getPref().getBoolean(PREF_CHECK_VALUES, true);
639        if (isBeforeUpload) {
640            checkValues = checkValues && Config.getPref().getBoolean(PREF_CHECK_VALUES_BEFORE_UPLOAD, true);
641        }
642
643        checkComplex = Config.getPref().getBoolean(PREF_CHECK_COMPLEX, true) && !checkerData.isEmpty();
644        if (isBeforeUpload) {
645            checkComplex = checkComplex && Config.getPref().getBoolean(PREF_CHECK_COMPLEX_BEFORE_UPLOAD, true);
646        }
647
648        checkFixmes = Config.getPref().getBoolean(PREF_CHECK_FIXMES, true);
649        if (isBeforeUpload) {
650            checkFixmes = checkFixmes && Config.getPref().getBoolean(PREF_CHECK_FIXMES_BEFORE_UPLOAD, true);
651        }
652    }
653
654    @Override
655    public void visit(Collection<OsmPrimitive> selection) {
656        if (checkKeys || checkValues || checkComplex || checkFixmes) {
657            super.visit(selection);
658        }
659    }
660
661    @Override
662    public void addGui(JPanel testPanel) {
663        GBC a = GBC.eol();
664        a.anchor = GridBagConstraints.EAST;
665
666        testPanel.add(new JLabel(name+" :"), GBC.eol().insets(3, 0, 0, 0));
667
668        prefCheckKeys = new JCheckBox(tr("Check property keys."), Config.getPref().getBoolean(PREF_CHECK_KEYS, true));
669        prefCheckKeys.setToolTipText(tr("Validate that property keys are valid checking against list of words."));
670        testPanel.add(prefCheckKeys, GBC.std().insets(20, 0, 0, 0));
671
672        prefCheckKeysBeforeUpload = new JCheckBox();
673        prefCheckKeysBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_KEYS_BEFORE_UPLOAD, true));
674        testPanel.add(prefCheckKeysBeforeUpload, a);
675
676        prefCheckComplex = new JCheckBox(tr("Use complex property checker."), Config.getPref().getBoolean(PREF_CHECK_COMPLEX, true));
677        prefCheckComplex.setToolTipText(tr("Validate property values and tags using complex rules."));
678        testPanel.add(prefCheckComplex, GBC.std().insets(20, 0, 0, 0));
679
680        prefCheckComplexBeforeUpload = new JCheckBox();
681        prefCheckComplexBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_COMPLEX_BEFORE_UPLOAD, true));
682        testPanel.add(prefCheckComplexBeforeUpload, a);
683
684        final Collection<String> sources = Config.getPref().getList(PREF_SOURCES, DEFAULT_SOURCES);
685        sourcesList = new EditableList(tr("TagChecker source"));
686        sourcesList.setItems(sources);
687        testPanel.add(new JLabel(tr("Data sources ({0})", "*.cfg")), GBC.eol().insets(23, 0, 0, 0));
688        testPanel.add(sourcesList, GBC.eol().fill(GridBagConstraints.HORIZONTAL).insets(23, 0, 0, 0));
689
690        ActionListener disableCheckActionListener = e -> handlePrefEnable();
691        prefCheckKeys.addActionListener(disableCheckActionListener);
692        prefCheckKeysBeforeUpload.addActionListener(disableCheckActionListener);
693        prefCheckComplex.addActionListener(disableCheckActionListener);
694        prefCheckComplexBeforeUpload.addActionListener(disableCheckActionListener);
695
696        handlePrefEnable();
697
698        prefCheckValues = new JCheckBox(tr("Check property values."), Config.getPref().getBoolean(PREF_CHECK_VALUES, true));
699        prefCheckValues.setToolTipText(tr("Validate that property values are valid checking against presets."));
700        testPanel.add(prefCheckValues, GBC.std().insets(20, 0, 0, 0));
701
702        prefCheckValuesBeforeUpload = new JCheckBox();
703        prefCheckValuesBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_VALUES_BEFORE_UPLOAD, true));
704        testPanel.add(prefCheckValuesBeforeUpload, a);
705
706        prefCheckFixmes = new JCheckBox(tr("Check for FIXMES."), Config.getPref().getBoolean(PREF_CHECK_FIXMES, true));
707        prefCheckFixmes.setToolTipText(tr("Looks for nodes or ways with FIXME in any property value."));
708        testPanel.add(prefCheckFixmes, GBC.std().insets(20, 0, 0, 0));
709
710        prefCheckFixmesBeforeUpload = new JCheckBox();
711        prefCheckFixmesBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_FIXMES_BEFORE_UPLOAD, true));
712        testPanel.add(prefCheckFixmesBeforeUpload, a);
713    }
714
715    /**
716     * Enables/disables the source list field
717     */
718    public void handlePrefEnable() {
719        boolean selected = prefCheckKeys.isSelected() || prefCheckKeysBeforeUpload.isSelected()
720                || prefCheckComplex.isSelected() || prefCheckComplexBeforeUpload.isSelected();
721        sourcesList.setEnabled(selected);
722    }
723
724    @Override
725    public boolean ok() {
726        enabled = prefCheckKeys.isSelected() || prefCheckValues.isSelected() || prefCheckComplex.isSelected() || prefCheckFixmes.isSelected();
727        testBeforeUpload = prefCheckKeysBeforeUpload.isSelected() || prefCheckValuesBeforeUpload.isSelected()
728                || prefCheckFixmesBeforeUpload.isSelected() || prefCheckComplexBeforeUpload.isSelected();
729
730        Config.getPref().putBoolean(PREF_CHECK_VALUES, prefCheckValues.isSelected());
731        Config.getPref().putBoolean(PREF_CHECK_COMPLEX, prefCheckComplex.isSelected());
732        Config.getPref().putBoolean(PREF_CHECK_KEYS, prefCheckKeys.isSelected());
733        Config.getPref().putBoolean(PREF_CHECK_FIXMES, prefCheckFixmes.isSelected());
734        Config.getPref().putBoolean(PREF_CHECK_VALUES_BEFORE_UPLOAD, prefCheckValuesBeforeUpload.isSelected());
735        Config.getPref().putBoolean(PREF_CHECK_COMPLEX_BEFORE_UPLOAD, prefCheckComplexBeforeUpload.isSelected());
736        Config.getPref().putBoolean(PREF_CHECK_KEYS_BEFORE_UPLOAD, prefCheckKeysBeforeUpload.isSelected());
737        Config.getPref().putBoolean(PREF_CHECK_FIXMES_BEFORE_UPLOAD, prefCheckFixmesBeforeUpload.isSelected());
738        return Config.getPref().putList(PREF_SOURCES, sourcesList.getItems());
739    }
740
741    @Override
742    public Command fixError(TestError testError) {
743        List<Command> commands = new ArrayList<>(50);
744
745        Collection<? extends OsmPrimitive> primitives = testError.getPrimitives();
746        for (OsmPrimitive p : primitives) {
747            Map<String, String> tags = p.getKeys();
748            if (tags.isEmpty()) {
749                continue;
750            }
751
752            for (Entry<String, String> prop: tags.entrySet()) {
753                String key = prop.getKey();
754                String value = prop.getValue();
755                if (value == null || value.trim().isEmpty()) {
756                    commands.add(new ChangePropertyCommand(p, key, null));
757                } else if (value.startsWith(" ") || value.endsWith(" ") || value.contains("  ")) {
758                    commands.add(new ChangePropertyCommand(p, key, Utils.removeWhiteSpaces(value)));
759                } else if (key.startsWith(" ") || key.endsWith(" ") || key.contains("  ")) {
760                    commands.add(new ChangePropertyKeyCommand(p, key, Utils.removeWhiteSpaces(key)));
761                } else {
762                    String evalue = Entities.unescape(value);
763                    if (!evalue.equals(value)) {
764                        commands.add(new ChangePropertyCommand(p, key, evalue));
765                    }
766                }
767            }
768        }
769
770        if (commands.isEmpty())
771            return null;
772        if (commands.size() == 1)
773            return commands.get(0);
774
775        return new SequenceCommand(tr("Fix tags"), commands);
776    }
777
778    @Override
779    public boolean isFixable(TestError testError) {
780        if (testError.getTester() instanceof TagChecker) {
781            int code = testError.getCode();
782            return code == INVALID_KEY || code == EMPTY_VALUES || code == INVALID_SPACE ||
783                   code == INVALID_KEY_SPACE || code == INVALID_HTML || code == MISSPELLED_VALUE ||
784                   code == MULTIPLE_SPACES;
785        }
786
787        return false;
788    }
789
790    protected static class CheckerData {
791        private String description;
792        protected List<CheckerElement> data = new ArrayList<>();
793        private OsmPrimitiveType type;
794        private TagCheckLevel level;
795        protected Severity severity;
796
797        private enum TagCheckLevel {
798            TAG_CHECK_ERROR(1250),
799            TAG_CHECK_WARN(1260),
800            TAG_CHECK_INFO(1270);
801
802            final int code;
803
804            TagCheckLevel(int code) {
805                this.code = code;
806            }
807        }
808
809        protected static class CheckerElement {
810            public Object tag;
811            public Object value;
812            public boolean noMatch;
813            public boolean tagAll;
814            public boolean valueAll;
815            public boolean valueBool;
816
817            private static Pattern getPattern(String str) {
818                if (str.endsWith("/i"))
819                    return Pattern.compile(str.substring(1, str.length()-2), Pattern.CASE_INSENSITIVE);
820                if (str.endsWith("/"))
821                    return Pattern.compile(str.substring(1, str.length()-1));
822
823                throw new IllegalStateException();
824            }
825
826            public CheckerElement(String exp) {
827                Matcher m = Pattern.compile("(.+)([!=]=)(.+)").matcher(exp);
828                m.matches();
829
830                String n = m.group(1).trim();
831
832                if ("*".equals(n)) {
833                    tagAll = true;
834                } else {
835                    tag = n.startsWith("/") ? getPattern(n) : n;
836                    noMatch = "!=".equals(m.group(2));
837                    n = m.group(3).trim();
838                    if ("*".equals(n)) {
839                        valueAll = true;
840                    } else if ("BOOLEAN_TRUE".equals(n)) {
841                        valueBool = true;
842                        value = OsmUtils.TRUE_VALUE;
843                    } else if ("BOOLEAN_FALSE".equals(n)) {
844                        valueBool = true;
845                        value = OsmUtils.FALSE_VALUE;
846                    } else {
847                        value = n.startsWith("/") ? getPattern(n) : n;
848                    }
849                }
850            }
851
852            public boolean match(Map<String, String> keys) {
853                for (Entry<String, String> prop: keys.entrySet()) {
854                    String key = prop.getKey();
855                    String val = valueBool ? OsmUtils.getNamedOsmBoolean(prop.getValue()) : prop.getValue();
856                    if ((tagAll || (tag instanceof Pattern ? ((Pattern) tag).matcher(key).matches() : key.equals(tag)))
857                            && (valueAll || (value instanceof Pattern ? ((Pattern) value).matcher(val).matches() : val.equals(value))))
858                        return !noMatch;
859                }
860                return noMatch;
861            }
862        }
863
864        private static final Pattern CLEAN_STR_PATTERN = Pattern.compile(" *# *([^#]+) *$");
865        private static final Pattern SPLIT_TRIMMED_PATTERN = Pattern.compile(" *: *");
866        private static final Pattern SPLIT_ELEMENTS_PATTERN = Pattern.compile(" *&& *");
867
868        public String getData(final String str) {
869            Matcher m = CLEAN_STR_PATTERN.matcher(str);
870            String trimmed = m.replaceFirst("").trim();
871            try {
872                description = m.group(1);
873                if (description != null && description.isEmpty()) {
874                    description = null;
875                }
876            } catch (IllegalStateException e) {
877                Logging.error(e);
878                description = null;
879            }
880            String[] n = SPLIT_TRIMMED_PATTERN.split(trimmed, 3);
881            switch (n[0]) {
882            case "way":
883                type = OsmPrimitiveType.WAY;
884                break;
885            case "node":
886                type = OsmPrimitiveType.NODE;
887                break;
888            case "relation":
889                type = OsmPrimitiveType.RELATION;
890                break;
891            case "*":
892                type = null;
893                break;
894            default:
895                return tr("Could not find element type");
896            }
897            if (n.length != 3)
898                return tr("Incorrect number of parameters");
899
900            switch (n[1]) {
901            case "W":
902                severity = Severity.WARNING;
903                level = TagCheckLevel.TAG_CHECK_WARN;
904                break;
905            case "E":
906                severity = Severity.ERROR;
907                level = TagCheckLevel.TAG_CHECK_ERROR;
908                break;
909            case "I":
910                severity = Severity.OTHER;
911                level = TagCheckLevel.TAG_CHECK_INFO;
912                break;
913            default:
914                return tr("Could not find warning level");
915            }
916            for (String exp: SPLIT_ELEMENTS_PATTERN.split(n[2])) {
917                try {
918                    data.add(new CheckerElement(exp));
919                } catch (IllegalStateException e) {
920                    Logging.trace(e);
921                    return tr("Illegal expression ''{0}''", exp);
922                } catch (PatternSyntaxException e) {
923                    Logging.trace(e);
924                    return tr("Illegal regular expression ''{0}''", exp);
925                }
926            }
927            return null;
928        }
929
930        public boolean match(OsmPrimitive osm, Map<String, String> keys) {
931            if (type != null && OsmPrimitiveType.from(osm) != type)
932                return false;
933
934            for (CheckerElement ce : data) {
935                if (!ce.match(keys))
936                    return false;
937            }
938            return true;
939        }
940
941        /**
942         * Returns the error description.
943         * @return the error description
944         */
945        public String getDescription() {
946            return description;
947        }
948
949        /**
950         * Returns the error severity.
951         * @return the error severity
952         */
953        public Severity getSeverity() {
954            return severity;
955        }
956
957        /**
958         * Returns the error code.
959         * @return the error code
960         */
961        public int getCode() {
962            if (type == null)
963                return level.code;
964
965            return level.code + type.ordinal() + 1;
966        }
967    }
968}
Note: See TracBrowser for help on using the repository browser.