diff --git a/css/80_app.css b/css/80_app.css index f96fbd7b98..612f4bfe3d 100644 --- a/css/80_app.css +++ b/css/80_app.css @@ -1996,7 +1996,7 @@ a.hide-toggle { } -/* Field - Localized Name +/* Field - Localized Name, Date ------------------------------------------------------- */ .form-field-input-localized > input.localized-main { border-radius: 0 0 0 4px; @@ -2004,10 +2004,12 @@ a.hide-toggle { .ideditor[dir='rtl'] .form-field-input-localized > input.localized-main { border-radius: 0 0 4px 0; } -.form-field-input-localized > button.localized-add { +.form-field-input-localized > button.localized-add, +.form-field-input-date > button.date-add { border-radius: 0 0 4px 0; } -.ideditor[dir='rtl'] .form-field-input-localized > button.localized-add { +.ideditor[dir='rtl'] .form-field-input-localized > button.localized-add, +.ideditor[dir='rtl'] .form-field-input-date > button.date-add { border-radius: 0 0 0 4px; } @@ -2020,18 +2022,21 @@ a.hide-toggle { cursor: not-allowed; } -/* nested subfields for name in different languages */ -.localized-multilingual { +/* nested subfields for name in different languages or date in different formats */ +.localized-multilingual, +.date-edtf { padding: 0 10px; flex-basis: 100%; } -.localized-multilingual .entry { +.localized-multilingual .entry, +.date-edtf .entry { position: relative; overflow: hidden; } /* draws a little line connecting the multilingual field up to the name field */ -.localized-multilingual .entry::before { +.localized-multilingual .entry::before, +.date-edtf .entry::before { content: ""; display: block; position: absolute; @@ -2049,7 +2054,8 @@ a.hide-toggle { border-top-width: 0; width: 100%; } -.localized-multilingual .entry .localized-value { +.localized-multilingual .entry .localized-value, +.date-edtf .entry .date-value { border-top-width: 0; border-radius: 0 0 4px 4px; width: 100%; @@ -2062,6 +2068,9 @@ a.hide-toggle { .ideditor .form-field-input-date > .combobox-caret + input.date-main { border-left: 0; } +.ideditor .form-field-input-date > input.date-main:last-of-type { + border-radius: 0; +} /* Field - Address diff --git a/data/core.yaml b/data/core.yaml index 536fdea8f8..d970409a0e 100644 --- a/data/core.yaml +++ b/data/core.yaml @@ -794,6 +794,9 @@ en: month: Month # placeholder for day of the month day: Day + edtf: Add EDTF date + edtf_label: Extended Date/Time Format + edtf_placeholder: 1849~, 1804?, 189X, 1906/1908, 1814-23, 1960-05-01T13:00... max_length_reached: "This string is longer than the maximum length of {maxChars} characters. Anything exceeding that length will be truncated." background: title: Background @@ -1844,10 +1847,21 @@ en: message_start: '{feature} has an invalid start date' message_end: '{feature} has an invalid end date' reference: 'A date must be formatted as YYYY-MM-DD, YYYY-MM, or YYYY.' + edtf: + message_start: '{feature} has an invalid EDTF start date' + message_end: '{feature} has an invalid EDTF end date' + reference: 'There is an unexpected "{token}" at character {position}.' line_as_area: message: '{feature} should be a line, not an area' line_as_point: message: '{feature} should be a line, not a point' + mismatched_dates: + title: Mismatched Dates + tip: Find dates that are contradictory or anachronistic + edtf: + message_start: '{feature} has a start date that falls outside of the EDTF start date' + message_end: '{feature} has an end date that falls outside of the EDTF end date' + reference: 'The basic date value should overlap with the range of possible dates described by the EDTF date.' mismatched_geometry: title: Mismatched Geometry tip: "Find features with conflicting tags and geometry" diff --git a/modules/ui/fields/date.js b/modules/ui/fields/date.js index 9cbaddd57b..2032e6ae92 100644 --- a/modules/ui/fields/date.js +++ b/modules/ui/fields/date.js @@ -1,10 +1,11 @@ import { dispatch as d3_dispatch } from 'd3-dispatch'; import { select as d3_select } from 'd3-selection'; -import * as countryCoder from '@ideditor/country-coder'; +import { svgIcon } from '../../svg'; +import { uiTooltip } from '../tooltip'; import { uiCombobox } from '../combobox'; import { t, localizer } from '../../core/localizer'; -import { utilGetSetValue, utilNoAuto, utilRebind, utilTotalExtent } from '../../util'; +import { utilGetSetValue, utilNoAuto, utilRebind, utilUniqueDomId } from '../../util'; export function uiFieldDate(field, context) { @@ -13,8 +14,13 @@ export function uiFieldDate(field, context) { let eraInput = d3_select(null); let monthInput = d3_select(null); let dayInput = d3_select(null); + let edtfInput = d3_select(null); let _entityIDs = []; let _tags; + let _selection = d3_select(null); + let _edtfValue; + + let edtfKey = field.key + ':edtf'; let dateTimeFormat = new Intl.DateTimeFormat(localizer.languageCode(), { year: 'numeric', @@ -100,9 +106,27 @@ export function uiFieldDate(field, context) { }; })); + let buttonTip = uiTooltip() + .title(() => t.append('inspector.date.edtf')) + .placement('left'); + + + // update _edtfValue + function calcEDTFValue(tags) { + if (_edtfValue && !tags[edtfKey]) { + // Don't unset the variable based on deleted tags, since this makes the UI + // disappear unexpectedly when clearing values - #8164 + _edtfValue = ''; + } else { + _edtfValue = tags[edtfKey]; + } + } + + function date(selection) { + _selection = selection; - var wrap = selection.selectAll('.form-field-input-wrap') + let wrap = selection.selectAll('.form-field-input-wrap') .data([0]); wrap = wrap.enter() @@ -169,6 +193,47 @@ export function uiFieldDate(field, context) { dayInput .on('change', change) .on('blur', change); + + if (_tags && _edtfValue === undefined) { + calcEDTFValue(_tags); + } + + let edtfButton = wrap.selectAll('.date-add') + .data([0]); + + edtfButton = edtfButton.enter() + .append('button') + .attr('class', 'date-add form-field-button') + .attr('aria-label', t('icons.plus')) + .call(svgIcon('#iD-icon-plus')) + .merge(edtfButton); + + edtfButton + .classed('disabled', typeof _edtfValue === 'string' || Array.isArray(_edtfValue)) + .call(buttonTip) + .on('click', addEDTF); + + edtfInput = selection.selectAll('.date-edtf') + .data([0]); + + edtfInput = edtfInput.enter() + .append('div') + .attr('class', 'date-edtf') + .merge(edtfInput); + + edtfInput + .call(renderEDTF); + } + + + function addEDTF(d3_event) { + d3_event.preventDefault(); + + if (typeof _edtfValue !== 'string' && !Array.isArray(_edtfValue)) { + _edtfValue = ''; + + edtfInput.call(renderEDTF); + } } @@ -210,6 +275,125 @@ export function uiFieldDate(field, context) { } + function changeEDTFValue(d3_event, d) { + let value = context.cleanTagValue(utilGetSetValue(d3_select(this))) || undefined; + + // don't override multiple values with blank string + if (!value && Array.isArray(d.value)) return; + + let t = {}; + t[edtfKey] = value; + d.value = value; + dispatch.call('change', this, t); + } + + + function renderEDTF(selection) { + let entries = selection.selectAll('div.entry') + .data((typeof _edtfValue === 'string' || Array.isArray(_edtfValue)) ? [_edtfValue] : []); + + entries.exit() + .style('top', '0') + .style('max-height', '240px') + .transition() + .duration(200) + .style('opacity', '0') + .style('max-height', '0px') + .remove(); + + let entriesEnter = entries.enter() + .append('div') + .attr('class', 'entry') + .each(function() { + var wrap = d3_select(this); + + let domId = utilUniqueDomId('edtf'); + let label = wrap + .append('label') + .attr('class', 'field-label') + .attr('for', domId); + + let text = label + .append('span') + .attr('class', 'label-text'); + + text + .append('span') + .attr('class', 'label-textvalue') + .call(t.append('inspector.date.edtf_label')); + + text + .append('span') + .attr('class', 'label-textannotation'); + + label + .append('button') + .attr('class', 'remove-icon-edtf') + .attr('title', t('icons.remove')) + .on('click', function(d3_event) { + d3_event.preventDefault(); + + // remove the UI item manually + _edtfValue = undefined; + + if (edtfKey && edtfKey in _tags) { + delete _tags[edtfKey]; + // remove from entity tags + let t = {}; + t[edtfKey] = undefined; + dispatch.call('change', this, t); + return; + } + + renderEDTF(selection); + }) + .call(svgIcon('#iD-operation-delete')); + + wrap + .append('input') + .attr('type', 'text') + .attr('class', 'date-value') + .on('blur', changeEDTFValue) + .on('change', changeEDTFValue); + }); + + entriesEnter + .style('margin-top', '0px') + .style('max-height', '0px') + .style('opacity', '0') + .transition() + .duration(200) + .style('margin-top', '10px') + .style('max-height', '240px') + .style('opacity', '1') + .on('end', function() { + d3_select(this) + .style('max-height', '') + .style('overflow', 'visible'); + }); + + entries = entries.merge(entriesEnter); + + entries.order(); + + // allow removing the entry UIs even if there isn't a tag to remove + entries.classed('present', true); + + utilGetSetValue(entries.select('.date-value'), function(d) { + return typeof d === 'string' ? d : ''; + }) + .attr('title', function(d) { + return Array.isArray(d) ? d.filter(Boolean).join('\n') : null; + }) + .attr('placeholder', function(d) { + return Array.isArray(d) ? t('inspector.multiple_values') : t('inspector.date.edtf_placeholder'); + }) + .classed('mixed', function(d) { + return Array.isArray(d); + }); + } + + date.tags = function(tags) { _tags = tags; @@ -250,6 +434,10 @@ export function uiFieldDate(field, context) { .attr('placeholder', t('inspector.date.month')); utilGetSetValue(dayInput, typeof dayValue === 'number' ? dayValue : '') .attr('placeholder', t('inspector.date.day')); + + calcEDTFValue(tags); + + _selection.call(date); }; @@ -260,7 +448,10 @@ export function uiFieldDate(field, context) { date.entityIDs = function(val) { + if (!arguments.length) return _entityIDs; _entityIDs = val; + _edtfValue = undefined; + return date; }; diff --git a/modules/validations/index.js b/modules/validations/index.js index 985c1203db..922e4b95ff 100644 --- a/modules/validations/index.js +++ b/modules/validations/index.js @@ -7,6 +7,7 @@ export { validationHelpRequest } from './help_request'; export { validationImpossibleOneway } from './impossible_oneway'; export { validationIncompatibleSource } from './incompatible_source'; export { validationMaprules } from './maprules'; +export { validationMismatchedDates } from './mismatched_dates'; export { validationMismatchedGeometry } from './mismatched_geometry'; export { validationMissingRole } from './missing_role'; export { validationMissingTag } from './missing_tag'; diff --git a/modules/validations/invalid_format.js b/modules/validations/invalid_format.js index 4df2b67896..50e596791c 100644 --- a/modules/validations/invalid_format.js +++ b/modules/validations/invalid_format.js @@ -3,6 +3,8 @@ import { localizer, t } from '../core/localizer'; import { utilDisplayLabel, utilNormalizeDateString } from '../util'; import { validationIssue, validationIssueFix } from '../core/validation'; +import * as edtf from 'edtf'; + export function validationFormatting() { var type = 'invalid_format'; @@ -71,6 +73,75 @@ export function validationFormatting() { validateDate('start_date', 'start'); validateDate('end_date', 'end'); + function showReferenceEDTF(selection, parserError) { + let message; + if (parserError.offset && parserError.token) { + message = t.append('issues.invalid_format.edtf.reference', { + token: parserError.token.value, + position: (parserError.offset + 1).toLocaleString(localizer.languageCode()), + }); + } else if (parserError.message) { + message = selection => selection.append('span') + .attr('class', 'localized-text') + .attr('lang', 'en') + .text(parserError.message.replace(/^edtf: /, '')); + } + if (!message) { + return; + } + + selection.selectAll('.issue-reference') + .data([0]) + .enter() + .append('div') + .attr('class', 'issue-reference') + .call(message); + } + + function validateEDTF(key, msgKey) { + key += ':edtf'; + if (!entity.tags[key]) return; + let parserError; + try { + edtf.parse(entity.tags[key]); + return; + } catch (e) { + parserError = e; + } + issues.push(new validationIssue({ + type: type, + subtype: 'date', + severity: 'warning', + message: function(context) { + var entity = context.hasEntity(this.entityIds[0]); + return entity ? t.append('issues.invalid_format.edtf.message_' + msgKey, + { feature: utilDisplayLabel(entity, context.graph()) }) : ''; + }, + reference: selection => showReferenceEDTF(selection, parserError), + entityIds: [entity.id], + hash: key + entity.tags[key], + dynamicFixes: function() { + var fixes = []; + 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; + } + })); + } + validateEDTF('start_date', 'start'); + validateEDTF('end_date', 'end'); + function isValidEmail(email) { // Emails in OSM are going to be official so they should be pretty simple // Using negated lists to better support all possible unicode characters (#6494) diff --git a/modules/validations/mismatched_dates.js b/modules/validations/mismatched_dates.js new file mode 100644 index 0000000000..6cd060011a --- /dev/null +++ b/modules/validations/mismatched_dates.js @@ -0,0 +1,111 @@ +import { actionChangeTags } from '../actions/change_tags'; +import { localizer, t } from '../core/localizer'; +import { utilDisplayLabel, utilNormalizeDateString } from '../util'; +import { validationIssue, validationIssueFix } from '../core/validation'; + +import * as edtf from 'edtf'; + +export function validationMismatchedDates() { + let type = 'mismatched_dates'; + + let validation = function(entity) { + let issues = []; + + function showReferenceEDTF(selection) { + selection.selectAll('.issue-reference') + .data([0]) + .enter() + .append('div') + .attr('class', 'issue-reference') + .call(t.append('issues.mismatched_dates.edtf.reference')); + } + + 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; + + issues.push(new validationIssue({ + type: type, + subtype: 'date', + severity: 'warning', + message: function(context) { + let entity = context.hasEntity(this.entityIds[0]); + return entity ? t.append('issues.mismatched_dates.edtf.message_' + msgKey, + { feature: utilDisplayLabel(entity, context.graph()) }) : ''; + }, + 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; + } + })); + } + validateEDTF('start_date', 'start'); + validateEDTF('end_date', 'end'); + + return issues; + }; + + validation.type = type; + + return validation; +} diff --git a/package.json b/package.json index 7c2c7221ab..d3b5a1b227 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "alif-toolkit": "^1.2.9", "core-js-bundle": "^3.19.0", "diacritics": "1.3.0", + "edtf": "^4.5.1", "fast-deep-equal": "~3.1.1", "fast-json-stable-stringify": "2.1.0", "lodash-es": "~4.17.15", @@ -86,7 +87,6 @@ "d3": "~7.8.1", "esbuild": "^0.17.3", "esbuild-visualizer": "^0.4.0", - "ohm-editor-layer-index": "github:openhistoricalmap/ohm-editor-layer-index#dist", "eslint": "^8.8.0", "fetch-mock": "^9.11.0", "gaze": "^1.1.3", @@ -106,6 +106,7 @@ "name-suggestion-index": "~6.0", "node-fetch": "^2.6.1", "npm-run-all": "^4.0.0", + "ohm-editor-layer-index": "github:openhistoricalmap/ohm-editor-layer-index#dist", "osm-community-index": "~5.5.0", "postcss": "^8.1.1", "postcss-selector-prepend": "^0.5.0", diff --git a/test/spec/ui/fields/date.js b/test/spec/ui/fields/date.js new file mode 100644 index 0000000000..0ba4405538 --- /dev/null +++ b/test/spec/ui/fields/date.js @@ -0,0 +1,63 @@ +describe('iD.uiFieldDate', function() { + let context, selection, field; + + beforeEach(function() { + context = iD.coreContext().assetPath('../dist/').init(); + selection = d3.select(document.createElement('div')); + field = iD.presetField('name', { key: 'start_date', type: 'date' }); + }); + + it('adds an EDTF field when the + button is clicked', function(done) { + var date = iD.uiFieldDate(field, context); + window.setTimeout(function() { // async, so data will be available + selection.call(date); + happen.click(selection.selectAll('.date-add').node()); + expect(selection.selectAll('.date-value').nodes().length).to.equal(1); + done(); + }, 20); + }); + + it('creates an EDTF tag after setting the value', function(done) { + var date = iD.uiFieldDate(field, context); + window.setTimeout(function() { // async, so data will be available + selection.call(date); + happen.click(selection.selectAll('.date-add').node()); + + iD.utilGetSetValue(selection.selectAll('.date-value'), '1066-10-14T09:00'); + happen.once(selection.selectAll('.date-value').node(), {type: 'change'}); + + date.on('change', function(tags) { + expect(tags).to.eql({'start_date:edtf': '1066-10-14T09:00'}); + }); + + done(); + }, 20); + }); + + it('ignores similar keys like `end_date`', function(done) { + var date = iD.uiFieldDate(field, context); + window.setTimeout(function() { // async, so data will be available + selection.call(date); + date.tags({'end_date:edtf': '1066-10-14T09:00'}); + + expect(selection.selectAll('.date-value').empty()).to.be.ok; + done(); + }, 20); + }); + + it('removes the tag when the value is emptied', function(done) { + var date = iD.uiFieldDate(field, context); + window.setTimeout(function() { // async, so data will be available + selection.call(date); + date.tags({'start_date:edtf': '1066-10-14T09:00'}); + + date.on('change', function(tags) { + expect(tags).to.eql({'start_date:edtf': undefined}); + }); + + iD.utilGetSetValue(selection.selectAll('.date-value'), ''); + happen.once(selection.selectAll('.date-value').node(), {type: 'change'}); + done(); + }, 20); + }); +}); diff --git a/test/spec/validations/invalid_format.js b/test/spec/validations/invalid_format.js new file mode 100644 index 0000000000..da7547a0b6 --- /dev/null +++ b/test/spec/validations/invalid_format.js @@ -0,0 +1,53 @@ +describe('iD.validations.invalid_format', function () { + var context; + + beforeEach(function() { + context = iD.coreContext().assetPath('../dist/').init(); + }); + + function createNode(tags) { + let n = iD.osmNode({id: 'n-1', loc: [4,4], tags: tags}); + + context.perform( + iD.actionAddEntity(n) + ); + } + + function validate() { + var validator = iD.validationFormatting(context); + 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() { + var issues = validate(); + expect(issues).to.have.lengthOf(0); + }); + + it('ignores way with no EDTF tag', function() { + createNode({ natural: 'tree', name: 'Arbre du Ténéré', start_date: '1673', end_date: '1973' }); + var issues = validate(); + expect(issues).to.have.lengthOf(0); + }); + + it('ignores way with okay EDTF tag', function() { + createNode({ natural: 'tree', name: 'The Tree That Owns Itself', 'start_date:edtf': '1550~/1900~', end_date: '1942' }); + var issues = validate(); + expect(issues).to.have.lengthOf(0); + }); + + it('flags way with invalid EDTF tag', function() { + createNode({ natural: 'tree', name: 'The Tree That Owns Itself', 'start_date:edtf': '155X~/1900~', end_date: '1942' }); + var issues = validate(); + expect(issues).to.have.lengthOf(1); + var issue = issues[0]; + expect(issue.type).to.eql('invalid_format'); + expect(issue.entityIds).to.have.lengthOf(1); + expect(issue.entityIds[0]).to.eql('n-1'); + }); +}); diff --git a/test/spec/validations/mismatched_dates.js b/test/spec/validations/mismatched_dates.js new file mode 100644 index 0000000000..ee99cd56b4 --- /dev/null +++ b/test/spec/validations/mismatched_dates.js @@ -0,0 +1,53 @@ +describe('iD.validations.mismatched_dates', function () { + let context; + + beforeEach(function() { + context = iD.coreContext().assetPath('../dist/').init(); + }); + + function createNode(tags) { + let n = iD.osmNode({id: 'n-1', loc: [4,4], tags: tags}); + + context.perform( + iD.actionAddEntity(n) + ); + } + + function validate() { + let validator = iD.validationMismatchedDates(context); + let changes = context.history().changes(); + let entities = changes.modified.concat(changes.created); + let issues = []; + entities.forEach(function(entity) { + issues = issues.concat(validator(entity, context.graph())); + }); + return issues; + } + + it('has no errors on init', function() { + let issues = validate(); + expect(issues).to.have.lengthOf(0); + }); + + it('ignores way with no EDTF tag', function() { + createNode({ natural: 'tree', name: 'Arbre du Ténéré', start_date: '1673', end_date: '1973' }); + let issues = validate(); + expect(issues).to.have.lengthOf(0); + }); + + it('ignores way with date within EDTF range', function() { + createNode({ natural: 'tree', name: 'The Tree That Owns Itself', start_date: '1900', 'start_date:edtf': '1550~/1900~', end_date: '1942' }); + let issues = validate(); + expect(issues).to.have.lengthOf(0); + }); + + it('flags way with date outside of EDTF range', function() { + createNode({ natural: 'tree', name: 'The Tree That Owns Itself', start_date: '1901', 'start_date:edtf': '1550~/1900~', end_date: '1942' }); + let issues = validate(); + expect(issues).to.have.lengthOf(1); + let issue = issues[0]; + expect(issue.type).to.eql('mismatched_dates'); + expect(issue.entityIds).to.have.lengthOf(1); + expect(issue.entityIds[0]).to.eql('n-1'); + }); +});