Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions data/core.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1839,9 +1839,6 @@ en:
amap: "Amap products are proprietary and must not be used as references."
baidu: "Baidu products are proprietary and must not be used as references."
google: "Google products are proprietary and must not be used as references."
incorrect_name:
message: '{feature} has the mistaken name "{name}"'
message_language: '{feature} has the mistaken name "{name}" in {language}'
invalid_format:
title: Invalid Formatting
tip: Find tags with unexpected formats
Expand Down Expand Up @@ -1873,6 +1870,15 @@ en:
message: "{feature} has no descriptive tags"
relation_type:
message: "{feature} is a relation without a type"
mutually_exclusive_tags:
title: Contradictory Tags
tip: "Find features that have contradictory tags."
default:
message: "{feature} has both {tag1} and {tag2}"
reference: 'The tags "{tag1}" and "{tag2}" contradict each other. Only one tag should be set.'
same_value:
message: "{feature}: {tag1} and {tag2} have same value"
reference: 'The tags "{tag1}" and "{tag2}" have the same value. Only one tag should be set.'
old_multipolygon:
message: "{multipolygon} has misplaced tags"
reference: "Multipolygons should be tagged on their relation, not their outer way."
Expand Down Expand Up @@ -1998,6 +2004,9 @@ en:
remove_tag:
title: Remove the tag
annotation: Removed tag.
remove_named_tag:
title: 'Remove the "{tag}" tag'
annotation: 'Removed "{tag}" tag.'
remove_tags:
title: Remove the tags
remove_the_name:
Expand Down
12 changes: 12 additions & 0 deletions modules/osm/tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,15 @@ export function isColourValid(value) {
}
return true;
}

// https://wiki.openstreetmap.org/wiki/Special:WhatLinksHere/Property:P44
export var osmMutuallyExclusiveTagPairs = [
['noname', 'name'],
['noref', 'ref'],
['nohousenumber', 'addr:housenumber'],
['noaddress', 'addr:housenumber'],
['noaddress', 'addr:housename'],
['noaddress', 'addr:unit'],
['addr:nostreet', 'addr:street']
];

1 change: 1 addition & 0 deletions modules/validations/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { validationMaprules } from './maprules';
export { validationMismatchedGeometry } from './mismatched_geometry';
export { validationMissingRole } from './missing_role';
export { validationMissingTag } from './missing_tag';
export { validationMutuallyExclusiveTags } from './mutually_exclusive_tags';
export { validationOutdatedTags } from './outdated_tags';
export { validationPrivateData } from './private_data';
export { validationSuspiciousName } from './suspicious_name';
Expand Down
93 changes: 93 additions & 0 deletions modules/validations/mutually_exclusive_tags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { actionChangeTags } from '../actions/change_tags';
import { t } from '../core/localizer';
import { utilDisplayLabel } from '../util';
import { validationIssue, validationIssueFix } from '../core/validation';
import { osmMutuallyExclusiveTagPairs } from '../osm/tags';

export function validationMutuallyExclusiveTags(/* context */) {
const type = 'mutually_exclusive_tags';

// https://wiki.openstreetmap.org/wiki/Special:WhatLinksHere/Property:P44
Comment thread
mtmail marked this conversation as resolved.
const tagKeyPairs = osmMutuallyExclusiveTagPairs;

const validation = function checkMutuallyExclusiveTags(entity /*, graph */) {

let pairsFounds = tagKeyPairs.filter((pair) => {
return (pair[0] in entity.tags && pair[1] in entity.tags);
Comment thread
mtmail marked this conversation as resolved.
}).filter((pair) => {
// noname=no is double-negation, thus positive and not conflicting. We'll ignore those
return !((pair[0].match(/^(addr:)?no[a-z]/) && entity.tags[pair[0]] === 'no') ||
(pair[1].match(/^(addr:)?no[a-z]/) && entity.tags[pair[1]] === 'no'));
});

// Additional:
// Check if name and not:name (and similar) are set and both have the same value
Comment thread
mtmail marked this conversation as resolved.
// not:name can actually have multiple values, separate by ;
// https://taginfo.openstreetmap.org/search?q=not%3A#keys
Object.keys(entity.tags).forEach((key) => {
let negative_key = 'not:' + key;
if (negative_key in entity.tags && entity.tags[negative_key].split(';').includes(entity.tags[key])) {
pairsFounds.push([negative_key, key, 'same_value']);
}
// For name:xx we also compare against the not:name tag
if (key.match(/^name:[a-z]+/)) {
negative_key = 'not:name';
if (negative_key in entity.tags && entity.tags[negative_key].split(';').includes(entity.tags[key])) {
pairsFounds.push([negative_key, key, 'same_value']);
}
}
});

let issues = pairsFounds.map((pair) => {
const subtype = pair[2] || 'default';
return new validationIssue({
type: type,
subtype: subtype,
severity: 'warning',
message: function(context) {
let entity = context.hasEntity(this.entityIds[0]);
return entity ? t.append(`issues.${type}.${subtype}.message`, {
feature: utilDisplayLabel(entity, context.graph()),
tag1: pair[0],
tag2: pair[1]
Comment thread
mtmail marked this conversation as resolved.
}) : '';
},
Comment thread
mtmail marked this conversation as resolved.
reference: (selection) => showReference(selection, pair, subtype),
entityIds: [entity.id],
dynamicFixes: () => pair.slice(0,2).map((tagToRemove) => createIssueFix(tagToRemove))
});
});

function createIssueFix(tagToRemove) {
return new validationIssueFix({
icon: 'iD-operation-delete',
title: t.append('issues.fix.remove_named_tag.title', { tag: tagToRemove }),
onClick: function(context) {
const entityId = this.issue.entityIds[0];
const entity = context.entity(entityId);
let tags = Object.assign({}, entity.tags); // shallow copy
delete tags[tagToRemove];
context.perform(
actionChangeTags(entityId, tags),
t('issues.fix.remove_named_tag.annotation', { tag: tagToRemove })
);
}
});
}

function showReference(selection, pair, subtype) {
selection.selectAll('.issue-reference')
.data([0])
.enter()
.append('div')
.attr('class', 'issue-reference')
.call(t.append(`issues.${type}.${subtype}.reference`, { tag1: pair[0], tag2: pair[1] }));
}

return issues;
};

validation.type = type;

return validation;
}
58 changes: 1 addition & 57 deletions modules/validations/suspicious_name.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,53 +96,6 @@ export function validationSuspiciousName() {
}
}

function makeIncorrectNameIssue(entityId, nameKey, incorrectName, langCode) {
return new validationIssue({
type: type,
subtype: 'not_name',
severity: 'warning',
message: function(context) {
const entity = context.hasEntity(this.entityIds[0]);
if (!entity) return '';
const preset = presetManager.match(entity, context.graph());
const langName = langCode && localizer.languageName(langCode);
return t.append('issues.incorrect_name.message' + (langName ? '_language' : ''),
{ feature: preset.name(), name: incorrectName, language: langName }
);
},
reference: showReference,
entityIds: [entityId],
hash: `${nameKey}=${incorrectName}`,
dynamicFixes: function() {
return [
new validationIssueFix({
icon: 'iD-operation-delete',
title: t.append('issues.fix.remove_the_name.title'),
onClick: function(context) {
const entityId = this.issue.entityIds[0];
const entity = context.entity(entityId);
let tags = Object.assign({}, entity.tags); // shallow copy
delete tags[nameKey];
context.perform(
actionChangeTags(entityId, tags), t('issues.fix.remove_mistaken_name.annotation')
);
}
})
];
}
});

function showReference(selection) {
selection.selectAll('.issue-reference')
.data([0])
.enter()
.append('div')
.attr('class', 'issue-reference')
.call(t.append('issues.generic_name.reference'));
}
}


let validation = function checkGenericName(entity) {
const tags = entity.tags;

Expand All @@ -151,23 +104,14 @@ export function validationSuspiciousName() {
if (hasWikidata) return [];

let issues = [];
const notNames = (tags['not:name'] || '').split(';');

for (let key in tags) {
const m = key.match(/^name(?:(?::)([a-zA-Z_-]+))?$/);
if (!m) continue;

const langCode = m.length >= 2 ? m[1] : null;
const value = tags[key];
if (notNames.length) {
for (let i in notNames) {
const notName = notNames[i];
if (notName && value === notName) {
issues.push(makeIncorrectNameIssue(entity.id, key, value, langCode));
continue;
}
}
}

if (isGenericName(value, tags)) {
issues.provisional = _waitingForNsi; // retry later if we are waiting on NSI to finish loading
issues.push(makeGenericNameIssue(entity.id, key, value, langCode));
Expand Down
92 changes: 92 additions & 0 deletions test/spec/validations/mutually_exclusive_tags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
describe('iD.validations.mutually_exclusive_tags', function () {
var context;

beforeEach(function() {
context = iD.coreContext().init();
});

function createNode(tags) {
context.perform(
iD.actionAddEntity({id: 'n-1', loc: [4,4], tags: tags})
);
}

function validate(validator) {
var changes = context.history().changes();
var entities = changes.modified.concat(changes.created);
var issues = [];
entities.forEach(function(entity) {
issues = issues.concat(validator(entity, context.graph()));
});
return issues;
}


it('has no errors on init', function(done) {
var validator = iD.validationMutuallyExclusiveTags(context);
window.setTimeout(function() { // async, so data will be available
var issues = validate(validator);
expect(issues).to.have.lengthOf(0);
done();
}, 20);
});

it('has no errors on good tags', function(done) {
createNode({'name': 'Trader Joe', 'not:name': 'Trader Jane'});
var validator = iD.validationMutuallyExclusiveTags(context);
window.setTimeout(function() { // async, so data will be available
var issues = validate(validator);
expect(issues).to.have.lengthOf(0);
done();
}, 20);
});

it('flags mutually exclusive tags', function(done) {
createNode({'name': 'Trader Joe', 'noname': 'yes'});
var validator = iD.validationMutuallyExclusiveTags(context);
window.setTimeout(function() { // async, so data will be available
var issues = validate(validator);
expect(issues).to.have.lengthOf(1);
var issue = issues[0];
expect(issue.type).to.eql('mutually_exclusive_tags');
expect(issue.subtype).to.eql('default');
expect(issue.severity).to.eql('warning');
expect(issue.entityIds).to.have.lengthOf(1);
expect(issue.entityIds[0]).to.eql('n-1');
done();
}, 20);
});

it('flags feature with a mutually exclusive `not:name` value', function(done) {
createNode({ shop: 'supermarket', name: 'Lous', 'not:name': 'Lous' });
var validator = iD.validationMutuallyExclusiveTags(context);
window.setTimeout(function() { // async, so data will be available
var issues = validate(validator);
expect(issues).to.have.lengthOf(1);
var issue = issues[0];
expect(issue.type).to.eql('mutually_exclusive_tags');
expect(issue.subtype).to.eql('same_value');
expect(issue.severity).to.eql('warning');
expect(issue.entityIds).to.have.lengthOf(1);
expect(issue.entityIds[0]).to.eql('n-1');
done();
}, 20);
});


it('flags feature with a mutually exclusive semicolon-separated `not:name` value', function(done) {
createNode({ shop: 'supermarket', name: 'Lous', 'not:name': 'Louis\';Lous;Louis\'s' });
var validator = iD.validationMutuallyExclusiveTags(context);
window.setTimeout(function() { // async, so data will be available
var issues = validate(validator);
expect(issues).to.have.lengthOf(1);
var issue = issues[0];
expect(issue.type).to.eql('mutually_exclusive_tags');
expect(issue.subtype).to.eql('same_value');
expect(issue.severity).to.eql('warning');
expect(issue.entityIds).to.have.lengthOf(1);
expect(issue.entityIds[0]).to.eql('n-1');
done();
}, 20);
});
});
41 changes: 0 additions & 41 deletions test/spec/validations/suspicious_name.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,45 +183,4 @@ describe('iD.validations.suspicious_name', function () {
done();
}, 20);
});

it('ignores feature with a non-matching `not:name` tag', function(done) {
createWay({ shop: 'supermarket', name: 'Lou\'s', 'not:name': 'Lous' });
var validator = iD.validationSuspiciousName(context);
window.setTimeout(function() { // async, so data will be available
var issues = validate(validator);
expect(issues).to.have.lengthOf(0);
done();
}, 20);
});

it('flags feature with a matching `not:name` tag', function(done) {
createWay({ shop: 'supermarket', name: 'Lous', 'not:name': 'Lous' });
var validator = iD.validationSuspiciousName(context);
window.setTimeout(function() { // async, so data will be available
var issues = validate(validator);
expect(issues).to.have.lengthOf(1);
var issue = issues[0];
expect(issue.type).to.eql('suspicious_name');
expect(issue.subtype).to.eql('not_name');
expect(issue.entityIds).to.have.lengthOf(1);
expect(issue.entityIds[0]).to.eql('w-1');
done();
}, 20);
});

it('flags feature with a matching a semicolon-separated `not:name` tag', function(done) {
createWay({ shop: 'supermarket', name: 'Lous', 'not:name': 'Louis\';Lous;Louis\'s' });
window.setTimeout(function() { // async, so data will be available
var validator = iD.validationSuspiciousName(context);
var issues = validate(validator);
expect(issues).to.have.lengthOf(1);
var issue = issues[0];
expect(issue.type).to.eql('suspicious_name');
expect(issue.subtype).to.eql('not_name');
expect(issue.entityIds).to.have.lengthOf(1);
expect(issue.entityIds[0]).to.eql('w-1');
done();
}, 20);
});

});