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

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

see #17055 improve TagChecker: Don't say "looks like" when all characters of given value are different, e.g. "Value '10' for key 'fee' looks like 'no'." makes no sense. The Levenshtein distance from 10 to no is only 2, but the length is also only 2.

  • Property svn:eol-style set to native
File size: 41.7 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                            if (fixVals.size() < 2) {
571                                fixedValue = closest;
572                            } else {
573                                Collections.sort(fixVals);
574                                // misspelled preset value with multiple good alternatives
575                                errors.add(TestError.builder(this, Severity.WARNING, MISSPELLED_VALUE_NO_FIX)
576                                        .message(tr("Misspelled property value"),
577                                                marktr("Value ''{0}'' for key ''{1}'' looks like one of {2}."), prop.getValue(), key, fixVals)
578                                        .primitives(p)
579                                        .build());
580                                withErrors.put(p, "WPV");
581                                continue;
582                            }
583                        }
584                    }
585                    if (fixedValue != null && possibleValues.contains(fixedValue)) {
586                        final String newValue = fixedValue;
587                        // misspelled preset value
588                        errors.add(TestError.builder(this, Severity.WARNING, MISSPELLED_VALUE)
589                                .message(tr("Misspelled property value"),
590                                        marktr("Value ''{0}'' for key ''{1}'' looks like ''{2}''."), prop.getValue(), key, newValue)
591                                .primitives(p)
592                                .fix(() -> new ChangePropertyCommand(p, key, newValue))
593                                .build());
594                        withErrors.put(p, "WPV");
595                    } else {
596                        // unknown preset value
597                        errors.add(TestError.builder(this, Severity.OTHER, INVALID_VALUE)
598                                .message(tr("Presets do not contain property value"),
599                                        marktr("Value ''{0}'' for key ''{1}'' not in presets."), prop.getValue(), key)
600                                .primitives(p)
601                                .build());
602                        withErrors.put(p, "UPV");
603                    }
604                }
605            }
606            if (checkFixmes && key != null && value != null && !value.isEmpty() && isFixme(key, value) && !withErrors.contains(p, "FIXME")) {
607               errors.add(TestError.builder(this, Severity.OTHER, FIXME)
608                .message(tr("FIXMES"))
609                .primitives(p)
610                .build());
611               withErrors.put(p, "FIXME");
612            }
613        }
614    }
615
616    private static boolean isFixme(String key, String value) {
617        return key.toLowerCase(Locale.ENGLISH).contains("fixme") || key.contains("todo")
618          || value.toLowerCase(Locale.ENGLISH).contains("fixme") || value.contains("check and delete");
619    }
620
621    private static String harmonizeKey(String key) {
622        return Utils.strip(key.toLowerCase(Locale.ENGLISH).replace('-', '_').replace(':', '_').replace(' ', '_'), "-_;:,");
623    }
624
625    private static String harmonizeValue(String value) {
626        return Utils.strip(value.toLowerCase(Locale.ENGLISH).replace('-', '_').replace(' ', '_'), "-_;:,");
627    }
628
629    @Override
630    public void startTest(ProgressMonitor monitor) {
631        super.startTest(monitor);
632        checkKeys = Config.getPref().getBoolean(PREF_CHECK_KEYS, true);
633        if (isBeforeUpload) {
634            checkKeys = checkKeys && Config.getPref().getBoolean(PREF_CHECK_KEYS_BEFORE_UPLOAD, true);
635        }
636
637        checkValues = Config.getPref().getBoolean(PREF_CHECK_VALUES, true);
638        if (isBeforeUpload) {
639            checkValues = checkValues && Config.getPref().getBoolean(PREF_CHECK_VALUES_BEFORE_UPLOAD, true);
640        }
641
642        checkComplex = Config.getPref().getBoolean(PREF_CHECK_COMPLEX, true) && !checkerData.isEmpty();
643        if (isBeforeUpload) {
644            checkComplex = checkComplex && Config.getPref().getBoolean(PREF_CHECK_COMPLEX_BEFORE_UPLOAD, true);
645        }
646
647        checkFixmes = Config.getPref().getBoolean(PREF_CHECK_FIXMES, true);
648        if (isBeforeUpload) {
649            checkFixmes = checkFixmes && Config.getPref().getBoolean(PREF_CHECK_FIXMES_BEFORE_UPLOAD, true);
650        }
651    }
652
653    @Override
654    public void visit(Collection<OsmPrimitive> selection) {
655        if (checkKeys || checkValues || checkComplex || checkFixmes) {
656            super.visit(selection);
657        }
658    }
659
660    @Override
661    public void addGui(JPanel testPanel) {
662        GBC a = GBC.eol();
663        a.anchor = GridBagConstraints.EAST;
664
665        testPanel.add(new JLabel(name+" :"), GBC.eol().insets(3, 0, 0, 0));
666
667        prefCheckKeys = new JCheckBox(tr("Check property keys."), Config.getPref().getBoolean(PREF_CHECK_KEYS, true));
668        prefCheckKeys.setToolTipText(tr("Validate that property keys are valid checking against list of words."));
669        testPanel.add(prefCheckKeys, GBC.std().insets(20, 0, 0, 0));
670
671        prefCheckKeysBeforeUpload = new JCheckBox();
672        prefCheckKeysBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_KEYS_BEFORE_UPLOAD, true));
673        testPanel.add(prefCheckKeysBeforeUpload, a);
674
675        prefCheckComplex = new JCheckBox(tr("Use complex property checker."), Config.getPref().getBoolean(PREF_CHECK_COMPLEX, true));
676        prefCheckComplex.setToolTipText(tr("Validate property values and tags using complex rules."));
677        testPanel.add(prefCheckComplex, GBC.std().insets(20, 0, 0, 0));
678
679        prefCheckComplexBeforeUpload = new JCheckBox();
680        prefCheckComplexBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_COMPLEX_BEFORE_UPLOAD, true));
681        testPanel.add(prefCheckComplexBeforeUpload, a);
682
683        final Collection<String> sources = Config.getPref().getList(PREF_SOURCES, DEFAULT_SOURCES);
684        sourcesList = new EditableList(tr("TagChecker source"));
685        sourcesList.setItems(sources);
686        testPanel.add(new JLabel(tr("Data sources ({0})", "*.cfg")), GBC.eol().insets(23, 0, 0, 0));
687        testPanel.add(sourcesList, GBC.eol().fill(GridBagConstraints.HORIZONTAL).insets(23, 0, 0, 0));
688
689        ActionListener disableCheckActionListener = e -> handlePrefEnable();
690        prefCheckKeys.addActionListener(disableCheckActionListener);
691        prefCheckKeysBeforeUpload.addActionListener(disableCheckActionListener);
692        prefCheckComplex.addActionListener(disableCheckActionListener);
693        prefCheckComplexBeforeUpload.addActionListener(disableCheckActionListener);
694
695        handlePrefEnable();
696
697        prefCheckValues = new JCheckBox(tr("Check property values."), Config.getPref().getBoolean(PREF_CHECK_VALUES, true));
698        prefCheckValues.setToolTipText(tr("Validate that property values are valid checking against presets."));
699        testPanel.add(prefCheckValues, GBC.std().insets(20, 0, 0, 0));
700
701        prefCheckValuesBeforeUpload = new JCheckBox();
702        prefCheckValuesBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_VALUES_BEFORE_UPLOAD, true));
703        testPanel.add(prefCheckValuesBeforeUpload, a);
704
705        prefCheckFixmes = new JCheckBox(tr("Check for FIXMES."), Config.getPref().getBoolean(PREF_CHECK_FIXMES, true));
706        prefCheckFixmes.setToolTipText(tr("Looks for nodes or ways with FIXME in any property value."));
707        testPanel.add(prefCheckFixmes, GBC.std().insets(20, 0, 0, 0));
708
709        prefCheckFixmesBeforeUpload = new JCheckBox();
710        prefCheckFixmesBeforeUpload.setSelected(Config.getPref().getBoolean(PREF_CHECK_FIXMES_BEFORE_UPLOAD, true));
711        testPanel.add(prefCheckFixmesBeforeUpload, a);
712    }
713
714    /**
715     * Enables/disables the source list field
716     */
717    public void handlePrefEnable() {
718        boolean selected = prefCheckKeys.isSelected() || prefCheckKeysBeforeUpload.isSelected()
719                || prefCheckComplex.isSelected() || prefCheckComplexBeforeUpload.isSelected();
720        sourcesList.setEnabled(selected);
721    }
722
723    @Override
724    public boolean ok() {
725        enabled = prefCheckKeys.isSelected() || prefCheckValues.isSelected() || prefCheckComplex.isSelected() || prefCheckFixmes.isSelected();
726        testBeforeUpload = prefCheckKeysBeforeUpload.isSelected() || prefCheckValuesBeforeUpload.isSelected()
727                || prefCheckFixmesBeforeUpload.isSelected() || prefCheckComplexBeforeUpload.isSelected();
728
729        Config.getPref().putBoolean(PREF_CHECK_VALUES, prefCheckValues.isSelected());
730        Config.getPref().putBoolean(PREF_CHECK_COMPLEX, prefCheckComplex.isSelected());
731        Config.getPref().putBoolean(PREF_CHECK_KEYS, prefCheckKeys.isSelected());
732        Config.getPref().putBoolean(PREF_CHECK_FIXMES, prefCheckFixmes.isSelected());
733        Config.getPref().putBoolean(PREF_CHECK_VALUES_BEFORE_UPLOAD, prefCheckValuesBeforeUpload.isSelected());
734        Config.getPref().putBoolean(PREF_CHECK_COMPLEX_BEFORE_UPLOAD, prefCheckComplexBeforeUpload.isSelected());
735        Config.getPref().putBoolean(PREF_CHECK_KEYS_BEFORE_UPLOAD, prefCheckKeysBeforeUpload.isSelected());
736        Config.getPref().putBoolean(PREF_CHECK_FIXMES_BEFORE_UPLOAD, prefCheckFixmesBeforeUpload.isSelected());
737        return Config.getPref().putList(PREF_SOURCES, sourcesList.getItems());
738    }
739
740    @Override
741    public Command fixError(TestError testError) {
742        List<Command> commands = new ArrayList<>(50);
743
744        Collection<? extends OsmPrimitive> primitives = testError.getPrimitives();
745        for (OsmPrimitive p : primitives) {
746            Map<String, String> tags = p.getKeys();
747            if (tags.isEmpty()) {
748                continue;
749            }
750
751            for (Entry<String, String> prop: tags.entrySet()) {
752                String key = prop.getKey();
753                String value = prop.getValue();
754                if (value == null || value.trim().isEmpty()) {
755                    commands.add(new ChangePropertyCommand(p, key, null));
756                } else if (value.startsWith(" ") || value.endsWith(" ") || value.contains("  ")) {
757                    commands.add(new ChangePropertyCommand(p, key, Utils.removeWhiteSpaces(value)));
758                } else if (key.startsWith(" ") || key.endsWith(" ") || key.contains("  ")) {
759                    commands.add(new ChangePropertyKeyCommand(p, key, Utils.removeWhiteSpaces(key)));
760                } else {
761                    String evalue = Entities.unescape(value);
762                    if (!evalue.equals(value)) {
763                        commands.add(new ChangePropertyCommand(p, key, evalue));
764                    }
765                }
766            }
767        }
768
769        if (commands.isEmpty())
770            return null;
771        if (commands.size() == 1)
772            return commands.get(0);
773
774        return new SequenceCommand(tr("Fix tags"), commands);
775    }
776
777    @Override
778    public boolean isFixable(TestError testError) {
779        if (testError.getTester() instanceof TagChecker) {
780            int code = testError.getCode();
781            return code == INVALID_KEY || code == EMPTY_VALUES || code == INVALID_SPACE ||
782                   code == INVALID_KEY_SPACE || code == INVALID_HTML || code == MISSPELLED_VALUE ||
783                   code == MULTIPLE_SPACES;
784        }
785
786        return false;
787    }
788
789    protected static class CheckerData {
790        private String description;
791        protected List<CheckerElement> data = new ArrayList<>();
792        private OsmPrimitiveType type;
793        private TagCheckLevel level;
794        protected Severity severity;
795
796        private enum TagCheckLevel {
797            TAG_CHECK_ERROR(1250),
798            TAG_CHECK_WARN(1260),
799            TAG_CHECK_INFO(1270);
800
801            final int code;
802
803            TagCheckLevel(int code) {
804                this.code = code;
805            }
806        }
807
808        protected static class CheckerElement {
809            public Object tag;
810            public Object value;
811            public boolean noMatch;
812            public boolean tagAll;
813            public boolean valueAll;
814            public boolean valueBool;
815
816            private static Pattern getPattern(String str) {
817                if (str.endsWith("/i"))
818                    return Pattern.compile(str.substring(1, str.length()-2), Pattern.CASE_INSENSITIVE);
819                if (str.endsWith("/"))
820                    return Pattern.compile(str.substring(1, str.length()-1));
821
822                throw new IllegalStateException();
823            }
824
825            public CheckerElement(String exp) {
826                Matcher m = Pattern.compile("(.+)([!=]=)(.+)").matcher(exp);
827                m.matches();
828
829                String n = m.group(1).trim();
830
831                if ("*".equals(n)) {
832                    tagAll = true;
833                } else {
834                    tag = n.startsWith("/") ? getPattern(n) : n;
835                    noMatch = "!=".equals(m.group(2));
836                    n = m.group(3).trim();
837                    if ("*".equals(n)) {
838                        valueAll = true;
839                    } else if ("BOOLEAN_TRUE".equals(n)) {
840                        valueBool = true;
841                        value = OsmUtils.TRUE_VALUE;
842                    } else if ("BOOLEAN_FALSE".equals(n)) {
843                        valueBool = true;
844                        value = OsmUtils.FALSE_VALUE;
845                    } else {
846                        value = n.startsWith("/") ? getPattern(n) : n;
847                    }
848                }
849            }
850
851            public boolean match(Map<String, String> keys) {
852                for (Entry<String, String> prop: keys.entrySet()) {
853                    String key = prop.getKey();
854                    String val = valueBool ? OsmUtils.getNamedOsmBoolean(prop.getValue()) : prop.getValue();
855                    if ((tagAll || (tag instanceof Pattern ? ((Pattern) tag).matcher(key).matches() : key.equals(tag)))
856                            && (valueAll || (value instanceof Pattern ? ((Pattern) value).matcher(val).matches() : val.equals(value))))
857                        return !noMatch;
858                }
859                return noMatch;
860            }
861        }
862
863        private static final Pattern CLEAN_STR_PATTERN = Pattern.compile(" *# *([^#]+) *$");
864        private static final Pattern SPLIT_TRIMMED_PATTERN = Pattern.compile(" *: *");
865        private static final Pattern SPLIT_ELEMENTS_PATTERN = Pattern.compile(" *&& *");
866
867        public String getData(final String str) {
868            Matcher m = CLEAN_STR_PATTERN.matcher(str);
869            String trimmed = m.replaceFirst("").trim();
870            try {
871                description = m.group(1);
872                if (description != null && description.isEmpty()) {
873                    description = null;
874                }
875            } catch (IllegalStateException e) {
876                Logging.error(e);
877                description = null;
878            }
879            String[] n = SPLIT_TRIMMED_PATTERN.split(trimmed, 3);
880            switch (n[0]) {
881            case "way":
882                type = OsmPrimitiveType.WAY;
883                break;
884            case "node":
885                type = OsmPrimitiveType.NODE;
886                break;
887            case "relation":
888                type = OsmPrimitiveType.RELATION;
889                break;
890            case "*":
891                type = null;
892                break;
893            default:
894                return tr("Could not find element type");
895            }
896            if (n.length != 3)
897                return tr("Incorrect number of parameters");
898
899            switch (n[1]) {
900            case "W":
901                severity = Severity.WARNING;
902                level = TagCheckLevel.TAG_CHECK_WARN;
903                break;
904            case "E":
905                severity = Severity.ERROR;
906                level = TagCheckLevel.TAG_CHECK_ERROR;
907                break;
908            case "I":
909                severity = Severity.OTHER;
910                level = TagCheckLevel.TAG_CHECK_INFO;
911                break;
912            default:
913                return tr("Could not find warning level");
914            }
915            for (String exp: SPLIT_ELEMENTS_PATTERN.split(n[2])) {
916                try {
917                    data.add(new CheckerElement(exp));
918                } catch (IllegalStateException e) {
919                    Logging.trace(e);
920                    return tr("Illegal expression ''{0}''", exp);
921                } catch (PatternSyntaxException e) {
922                    Logging.trace(e);
923                    return tr("Illegal regular expression ''{0}''", exp);
924                }
925            }
926            return null;
927        }
928
929        public boolean match(OsmPrimitive osm, Map<String, String> keys) {
930            if (type != null && OsmPrimitiveType.from(osm) != type)
931                return false;
932
933            for (CheckerElement ce : data) {
934                if (!ce.match(keys))
935                    return false;
936            }
937            return true;
938        }
939
940        /**
941         * Returns the error description.
942         * @return the error description
943         */
944        public String getDescription() {
945            return description;
946        }
947
948        /**
949         * Returns the error severity.
950         * @return the error severity
951         */
952        public Severity getSeverity() {
953            return severity;
954        }
955
956        /**
957         * Returns the error code.
958         * @return the error code
959         */
960        public int getCode() {
961            if (type == null)
962                return level.code;
963
964            return level.code + type.ordinal() + 1;
965        }
966    }
967}
Note: See TracBrowser for help on using the repository browser.