Skip to content

Commit

Permalink
Merge pull request #190 from OpenHistoricalMap/1ec5-validator-edtf-pr…
Browse files Browse the repository at this point in the history
…ecision-674

Fix bogus dates in labels and spurious mismatched date warnings and suggestions
  • Loading branch information
erictheise authored Feb 14, 2024
2 parents a69476d + 6ccba45 commit 975f054
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 98 deletions.
32 changes: 17 additions & 15 deletions modules/ui/fields/date.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,24 +249,26 @@ export function uiFieldDate(field, context) {

if (!yearValue) {
tag[field.key] = undefined;
} else if (isNaN(yearValue)) {
tag[field.key] = context.cleanTagValue(yearValue);
} else {
let value = '';
let year = parseInt(context.cleanTagValue(yearValue), 10);
if (eraValue === bceName) {
value += '-' + String(year - 1).padStart(4, '0');
} else {
value += String(year).padStart(4, '0');
}
let month = context.cleanTagValue(monthValue);
if (monthNames.includes(month)) {
month = monthNames.indexOf(month) + 1;
value += '-' + String(month).padStart(2, '0');
let day = parseInt(context.cleanTagValue(dayValue), 10);
if (!isNaN(day)) {
value += '-' + String(day).padStart(2, '0');
if (isNaN(year)) {
if (eraValue === bceName) {
value += '-' + String(year - 1).padStart(4, '0');
} else {
value += String(year).padStart(4, '0');
}
let month = context.cleanTagValue(monthValue);
if (monthNames.includes(month)) {
month = monthNames.indexOf(month) + 1;
value += '-' + String(month).padStart(2, '0');
let day = parseInt(context.cleanTagValue(dayValue), 10);
if (!isNaN(day)) {
value += '-' + String(day).padStart(2, '0');
}
}
} else {
value = context.cleanTagValue(yearValue);
}
tag[field.key] = value;
}
Expand Down Expand Up @@ -424,7 +426,7 @@ export function uiFieldDate(field, context) {
}
}

utilGetSetValue(yearInput, typeof yearValue === 'number' ? yearValue : '')
utilGetSetValue(yearInput, (typeof yearValue === 'number' || typeof yearValue === 'string') ? yearValue : '')
.attr('title', isMixed ? yearValue.filter(Boolean).join('\n') : null)
.attr('placeholder', isMixed ? t('inspector.multiple_values') : t('inspector.date.year'))
.classed('mixed', isMixed);
Expand Down
44 changes: 23 additions & 21 deletions modules/util/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,29 +186,31 @@ export function utilDisplayName(entity) {
let start = entity.tags.start_date && utilNormalizeDateString(entity.tags.start_date);
let end = entity.tags.end_date && utilNormalizeDateString(entity.tags.end_date);

// Keep the date range suffix succinct by only including the year and era.
let options = {timeZone: 'UTC'};
if (end) {
options.year = end.localeOptions.year;
options.era = end.localeOptions.era;
}
if (start) {
// Override any settings from the end of the range.
options.year = start.localeOptions.year;
options.era = start.localeOptions.era;
}
if (start || end) {
// Keep the date range suffix succinct by only including the year and era.
let options = {timeZone: 'UTC'};
if (end) {
options.year = end.localeOptions.year;
options.era = end.localeOptions.era;
}
if (start) {
// Override any settings from the end of the range.
options.year = start.localeOptions.year;
options.era = start.localeOptions.era;
}

// Get the date range format in structured form, then filter out anything untagged.
let format = new Intl.DateTimeFormat(localizer.languageCode(), options);
let lateDate = new Date(Date.UTC(9999));
let parts = format.formatRangeToParts(start ? start.date : lateDate, end ? end.date : lateDate);
if (!start) {
parts = parts.filter(p => p.source !== 'startRange');
}
if (!end) {
parts = parts.filter(p => p.source !== 'endRange');
// Get the date range format in structured form, then filter out anything untagged.
let format = new Intl.DateTimeFormat(localizer.languageCode(), options);
let lateDate = new Date(Date.UTC(9999));
let parts = format.formatRangeToParts(start ? start.date : lateDate, end ? end.date : lateDate);
if (!start) {
parts = parts.filter(p => p.source !== 'startRange');
}
if (!end) {
parts = parts.filter(p => p.source !== 'endRange');
}
dateRange = parts.map(p => p.value).join('');
}
dateRange = parts.map(p => p.value).join('');
}

var localizedNameKey = 'name:' + localizer.languageCode().toLowerCase();
Expand Down
151 changes: 89 additions & 62 deletions modules/validations/mismatched_dates.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,51 @@ import * as edtf from 'edtf';
export function validationMismatchedDates() {
let type = 'mismatched_dates';

function parseEDTF(value) {
try {
let parsed = edtf.default(value);

// According to edtf.js, an extended interval with an unknown start or end covers no date.
// This isn't useful for the purpose of testing whether the basic date matches, so treat it as an unspecified start or end.
if (parsed.lower === null) {
parsed.lower = Infinity;
}
if (parsed.upper === null) {
parsed.upper = Infinity;
}

return parsed;
} catch (e) {
// Already handled by invalid_format rule.
return;
}
}

function getReplacementDates(parsed) {
let likelyDates = new Set();

let valueFromDate = (date, precision) => {
date.precision = precision;
return date.edtf.split('T')[0];
};

if (Number.isFinite(parsed.min)) {
let min = edtf.default(parsed.min);
let precision = (parsed.lower || parsed.first || parsed).precision;
likelyDates.add(valueFromDate(min, precision));
}

if (Number.isFinite(parsed.max)) {
let max = edtf.default(parsed.max);
let precision = (parsed.upper || parsed.last || parsed).precision;
likelyDates.add(valueFromDate(max, precision));
}

let sortedDates = [...likelyDates];
sortedDates.sort();
return sortedDates;
}

let validation = function(entity) {
let issues = [];

Expand All @@ -20,16 +65,49 @@ export function validationMismatchedDates() {
.call(t.append('issues.mismatched_dates.edtf.reference'));
}

function getDynamicFixes(key, parsed) {
let fixes = [];

let replacementDates = getReplacementDates(parsed);
fixes.push(...replacementDates.map(value => {
let normalized = utilNormalizeDateString(value);
let localeDateString = normalized.date.toLocaleDateString(localizer.languageCode(), normalized.localeOptions);
return new validationIssueFix({
title: t.append('issues.fix.reformat_date.title', { date: localeDateString }),
onClick: function(context) {
context.perform(function(graph) {
var entityInGraph = graph.hasEntity(entity.id);
if (!entityInGraph) return graph;
var newTags = Object.assign({}, entityInGraph.tags);
newTags[key] = normalized.value;
return actionChangeTags(entityInGraph.id, newTags)(graph);
}, t('issues.fix.reformat_date.annotation'));
}
});
}));

fixes.push(new validationIssueFix({
icon: 'iD-operation-delete',
title: t.append('issues.fix.remove_tag.title'),
onClick: function(context) {
context.perform(function(graph) {
var entityInGraph = graph.hasEntity(entity.id);
if (!entityInGraph) return graph;
var newTags = Object.assign({}, entityInGraph.tags);
delete newTags[key];
return actionChangeTags(entityInGraph.id, newTags)(graph);
}, t('issues.fix.remove_tag.annotation'));
}
}));

return fixes;
}

function validateEDTF(key, msgKey) {
if (!entity.tags[key] || !entity.tags[key + ':edtf']) return;
let parsed;
try {
parsed = edtf.default(entity.tags[key + ':edtf']);
} catch (e) {
// Already handled by invalid_format rule.
return;
}
if (parsed.covers(edtf.default(entity.tags[key]))) return;
let basic = parseEDTF(entity.tags[key]);
let parsed = parseEDTF(entity.tags[key + ':edtf']);
if (!basic || !parsed || parsed.covers(basic)) return;

issues.push(new validationIssue({
type: type,
Expand All @@ -43,60 +121,7 @@ export function validationMismatchedDates() {
reference: showReferenceEDTF,
entityIds: [entity.id],
hash: key + entity.tags[key + ':edtf'],
dynamicFixes: function() {
let fixes = [];
let likelyDates = new Set();

let valueFromDate = date => {
date.precision = (parsed.lower || parsed.first || parsed).precision;
return date.edtf.split('T')[0];
};

if (Number.isFinite(parsed.min)) {
let min = edtf.default(parsed.min);
likelyDates.add(valueFromDate(min));
}

if (Number.isFinite(parsed.max)) {
let max = edtf.default(parsed.max);
likelyDates.add(valueFromDate(max));
}

let sortedDates = [...likelyDates];
sortedDates.sort();
fixes.push(...sortedDates.map(value => {
let normalized = utilNormalizeDateString(value);
let localeDateString = normalized.date.toLocaleDateString(localizer.languageCode(), normalized.localeOptions);
return new validationIssueFix({
title: t.append('issues.fix.reformat_date.title', { date: localeDateString }),
onClick: function(context) {
context.perform(function(graph) {
var entityInGraph = graph.hasEntity(entity.id);
if (!entityInGraph) return graph;
var newTags = Object.assign({}, entityInGraph.tags);
newTags[key] = normalized.value;
return actionChangeTags(entityInGraph.id, newTags)(graph);
}, t('issues.fix.reformat_date.annotation'));
}
});
}));

fixes.push(new validationIssueFix({
icon: 'iD-operation-delete',
title: t.append('issues.fix.remove_tag.title'),
onClick: function(context) {
context.perform(function(graph) {
var entityInGraph = graph.hasEntity(entity.id);
if (!entityInGraph) return graph;
var newTags = Object.assign({}, entityInGraph.tags);
delete newTags[key];
return actionChangeTags(entityInGraph.id, newTags)(graph);
}, t('issues.fix.remove_tag.annotation'));
}
}));

return fixes;
}
dynamicFixes: () => getDynamicFixes(key, parsed),
}));
}
validateEDTF('start_date', 'start');
Expand All @@ -106,6 +131,8 @@ export function validationMismatchedDates() {
};

validation.type = type;
validation.parseEDTF = parseEDTF;
validation.getReplacementDates = getReplacementDates;

return validation;
}
1 change: 1 addition & 0 deletions test/spec/util/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ describe('iD.util', function() {
expect(iD.utilDisplayName({tags: {name: 'Son of the Tree That Owns Itself', start_date: '1946-12-04'}})).to.eql('Son of the Tree That Owns Itself [1946 – ]');
expect(iD.utilDisplayName({tags: {name: 'Great Elm', end_date: '1876-02-15'}})).to.eql('Great Elm [ – 1876]');
expect(iD.utilDisplayName({tags: {name: 'Capitol Christmas Tree', start_date: '2021-11-19', end_date: '2021-12-25'}})).to.eql('Capitol Christmas Tree [2021]');
expect(iD.utilDisplayName({tags: {name: 'Binary Search Tree', start_date: '0b0110', end_date: '0b0110'}})).to.eql('Binary Search Tree');
});
});

Expand Down
17 changes: 17 additions & 0 deletions test/spec/validations/mismatched_dates.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,21 @@ describe('iD.validations.mismatched_dates', function () {
expect(issue.entityIds).to.have.lengthOf(1);
expect(issue.entityIds[0]).to.eql('n-1');
});

it('equates unknown date with unspecified date in EDTF extended interval', function() {
let validator = iD.validationMismatchedDates(context);
expect(validator.parseEDTF('/1234').toString()).to.equal(validator.parseEDTF('../1234').toString());
expect(validator.parseEDTF('5678/').toString()).to.equal(validator.parseEDTF('5678/..').toString());
expect(validator.parseEDTF('/').toString()).to.equal(validator.parseEDTF('../..').toString());
});

it('suggests replacing date with bounds of EDTF range', function() {
let validator = iD.validationMismatchedDates(context);
expect(validator.getReplacementDates(validator.parseEDTF('1234/..'))).to.deep.equal(['1234']);
expect(validator.getReplacementDates(validator.parseEDTF('../5678'))).to.deep.equal(['5678']);
expect(validator.getReplacementDates(validator.parseEDTF('1234/5678'))).to.deep.equal(['1234', '5678']);
expect(validator.getReplacementDates(validator.parseEDTF('1234-10/5678'))).to.deep.equal(['1234-10', '5678']);
expect(validator.getReplacementDates(validator.parseEDTF('1234/5678-10-11'))).to.deep.equal(['1234', '5678-10-11']);
expect(validator.getReplacementDates(validator.parseEDTF('1234/5678-10-11T12:13:14'))).to.deep.equal(['1234', '5678-10-11']);
});
});

0 comments on commit 975f054

Please sign in to comment.