From 4d5cb19c7f5e544fadf467e8fa4b4df3ad86ffbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Minh=20Nguy=E1=BB=85n?= Date: Sun, 17 Dec 2023 01:28:17 -0800 Subject: [PATCH] Extract EDTF date from OSM approximate date Also moved date utilities to a separate file. --- modules/util/date.js | 241 ++++++++++++++++++++++++ modules/util/index.js | 5 +- modules/util/util.js | 108 +---------- modules/validations/invalid_format.js | 55 ++++-- test/spec/util/date.js | 97 ++++++++++ test/spec/util/util.js | 70 ------- test/spec/validations/invalid_format.js | 10 + 7 files changed, 393 insertions(+), 193 deletions(-) create mode 100644 modules/util/date.js create mode 100644 test/spec/util/date.js diff --git a/modules/util/date.js b/modules/util/date.js new file mode 100644 index 0000000000..cc04862694 --- /dev/null +++ b/modules/util/date.js @@ -0,0 +1,241 @@ +import edtf, { Interval } from 'edtf'; + +// Returns whether two date component arrays represent the same date. +// A date component array contains the full date, followed by year, month, day. +// Dates are only compared to the lowest common precision. +function isSameDate(date1, date2) { + if (date1[1] !== date2[1]) return false; + if (date1[2] && date2[2] && date1[2] !== date2[2]) return false; + if (date1[3] && date2[3] && date1[3] !== date2[3]) return false; + return true; +} + +function isBefore(date1, date2) { + if (date1[1] > date2[1]) return false; + if (date1[2] && date2[2] && date1[2] > date2[2]) return false; + if (date1[3] && date2[3] && date1[3] > date2[3]) return false; + return true; +} + +function isAfter(date1, date2) { + if (date1[1] < date2[1]) return false; + if (date1[2] && date2[2] && date1[2] < date2[2]) return false; + if (date1[3] && date2[3] && date1[3] < date2[3]) return false; + return true; +} + +/** + * Returns whether the two sets of tags overlap temporally. + * + * @param {object} tags1 Tags to compare. + * @param {object} tags2 Tags to compare. + * @param {boolean?} touchIsOverlap True to consider it an overlap if one date range ends at the same time that the other begins. + */ +export function utilDatesOverlap(tags1, tags2, touchIsOverlap) { + // If the feature has a valid start date but no valid end date, assume it + // starts at the beginning of time. + var dateRegex = /^(-?\d{1,4})(?:-(\d\d))?(?:-(\d\d))?$/; + var minDate = ['-9999', '-9999'], + maxDate = ['9999', '9999'], + start1 = (tags1.start_date || '').match(dateRegex) || minDate, + start2 = (tags2.start_date || '').match(dateRegex) || minDate, + end1 = (tags1.end_date || '').match(dateRegex) || maxDate, + end2 = (tags2.end_date || '').match(dateRegex) || maxDate; + + if (isSameDate(end1, start2) || isSameDate(end2, start1)) { + return touchIsOverlap === true; + } + + return ((isAfter(start1, start2) && isBefore(start1, end2)) || + (isAfter(start2, start1) && isBefore(start2, end1)) || + (isAfter(end1, start2) && isBefore(end1, end2)) || + (isAfter(end2, start1) && isBefore(end2, end1))); +} + +// Returns an object containing the given date string normalized as an ISO 8601 date string and parsed as a Date object. +// Date components are padded for compatibility with tagging conventions. +// Dates such as January 0, February 31, and Duodecember 1 are wrapped to make more sense. +export function utilNormalizeDateString(raw) { + if (!raw) return null; + + var date; + + // Enforce the same date formats supported by DateFunctions-plpgsql and decimaldate-python. + var dateRegex = /^(-)?(\d+)(?:-(\d\d?)(?:-(\d\d?))?)?$/; + var match = raw.match(dateRegex); + if (match !== null) { + // Manually construct a Date. + // Passing the string directly into the Date constructor would throw an error on negative years. + date = new Date(0); + date.setUTCFullYear(parseInt((match[1] || '') + match[2], 10)); + if (match[3]) date.setUTCMonth(parseInt(match[3], 10) - 1); // 0-based + if (match[4]) date.setUTCDate(parseInt(match[4], 10)); + } else { + // Fall back on whatever the browser can parse into a date. + date = new Date(raw); + try { + date.toISOString(); + } catch (exc) { + return null; + } + } + + // Reconstruct the date string. + // Avoid Date.toISOString() because it has fixed precision and excessively pads negative years. + var normalized = ''; + if (match !== null && date.getUTCFullYear() < 0) { + var absYear = Math.abs(date.getUTCFullYear()); + normalized += '-' + String(absYear).padStart(4, '0'); + } else { + normalized += String(date.getUTCFullYear()).padStart(4, '0'); + } + if (match === null || match[3]) { + normalized += '-' + String(date.getUTCMonth() + 1).padStart(2, '0'); + } + if (match === null || match[4]) { + normalized += '-' + String(date.getUTCDate()).padStart(2, '0'); + } + return { + date: date, + value: normalized, + localeOptions: { + year: 'numeric', + era: date.getUTCFullYear() < 1 ? 'short' : undefined, + month: (match === null || match[3]) ? 'long' : undefined, + day: (match === null || match[4]) ? 'numeric' : undefined, + timeZone: 'UTC' + } + }; +} + +/** + * Converts a date in OSM approximate date format to EDTF. + */ +export function utilEDTFFromOSMDateString(osm) { + // https://wiki.openstreetmap.org/wiki/Key:start_date#Approximations + // https://wiki.openstreetmap.org/wiki/Special:Diff/510218/557626#Proposal_for_date_formatting + // https://www.loc.gov/standards/datetime/ + let [match, start, end, bc] = osm.match(/^(.+)\.\.(.+)( BCE?)?$/) || []; + if (match) { + if (!bc) bc = ''; + let startEDTF = utilEDTFFromOSMDateString(start + bc); + if (startEDTF) { + let parsed = edtf(startEDTF); + if (parsed instanceof Set) { + startEDTF = parsed.earlier ? '' : parsed.first.edtf; + } + if (parsed instanceof Interval) { + startEDTF = parsed.earlier ? '' : parsed.lower.edtf; + } + } + + let endEDTF = utilEDTFFromOSMDateString(end + bc); + if (endEDTF) { + let parsed = edtf(endEDTF); + if (parsed instanceof Set) { + endEDTF = parsed.later ? '' : parsed.last.edtf; + } + if (parsed instanceof Interval) { + endEDTF = parsed.later ? '' : parsed.upper.edtf; + } + } + + if (startEDTF && endEDTF) { + if (startEDTF.match(/[~?%]/) || endEDTF.match(/[~?%]/)) { + // Set notation is incompatible with qualifiers. + return `${startEDTF}/${endEDTF}`; + } else { + return `[${startEDTF}..${endEDTF}]`; + } + } + } + + let year, circa, monthDay; + [match, circa, year, monthDay, bc] = osm.match(/^(~)?(\d+)(-\d\d(?:-\d\d)?)?( BCE?)?$/) || []; + if (match) { + if (!circa) circa = ''; + if (!monthDay) monthDay = ''; + if (bc) { + year = '-' + String(parseInt(year, 10) - 1).padStart(4, '0'); + } else { + year = year.padStart(4, '0'); + } + return `${year}${monthDay}${circa}`; + } + + let decade; + [match, circa, decade, bc] = osm.match(/^(~)?(\d+)0s( BCE?)?$/) || []; + if (match) { + if (!circa) circa = ''; + if (!bc) { + return `${decade.padStart(3, '0')}X${circa}`; + } + let startYear = String(parseInt(decade, 10) * 10 + 8).padStart(4, '0'); + let endYear = String(parseInt(decade, 10) * 10 - 1).padStart(4, '0'); + return `-${startYear}${circa}/-${endYear}${circa}`; + } + + let century; + [match, circa, century, bc] = osm.match(/^(~)?C(\d+)( BCE?)?$/) || []; + if (match) { + if (!circa) circa = ''; + if (!bc) { + return `${String(parseInt(century, 10) - 1).padStart(2, '0')}XX${circa}`; + } + let startYear = String((parseInt(century, 10) - 1) * 100 + 98).padStart(4, '0'); + let endYear = String((parseInt(century, 10) - 1) * 100 - 1).padStart(4, '0'); + return `-${startYear}${circa}/-${endYear}${circa}`; + } + + let third; + [match, third, decade, bc] = osm.match(/^(early|mid|late) (\d+)0s( BCE?)?$/) || []; + if (match) { + // https://uhlibraries-digital.github.io/bcdams-map/guidelines/date + const offsetsByThird = { + early: [0, 3], + mid: [3, 7], + late: [7, 9], + }; + + let startYear = decade * 10 + offsetsByThird[third][bc ? 1 : 0]; + let endYear = decade * 10 + offsetsByThird[third][bc ? 0 : 1]; + if (bc) { + startYear = startYear + 1; + endYear = endYear + 1; + return `-${String(startYear).padStart(4, '0')}~/-${String(endYear).padStart(4, '0')}~`; + } else { + return `${String(startYear).padStart(4, '0')}~/${String(endYear).padStart(4, '0')}~`; + } + } + + [match, third, century, bc] = osm.match(/^(early|mid|late) C(\d+)( BCE?)?$/) || []; + if (match) { + // https://uhlibraries-digital.github.io/bcdams-map/guidelines/date + const offsetsByThird = { + early: [0, 30], + mid: [30, 70], + late: [70, 99], + }; + + century = parseInt(century, 10) - 1; + let startYear = century * 100 + offsetsByThird[third][bc ? 1 : 0]; + let endYear = century * 100 + offsetsByThird[third][bc ? 0 : 1]; + if (bc) { + startYear = startYear + 1; + endYear = endYear + 1; + return `-${String(startYear).padStart(4, '0')}~/-${String(endYear).padStart(4, '0')}~`; + } else { + return `${String(startYear).padStart(4, '0')}~/${String(endYear).padStart(4, '0')}~`; + } + } + + [match, end] = osm.match(/^before (\d{4}(?:-\d\d)?(?:-\d\d)?)$/) || []; + if (match) { + return `[..${end}]`; + } + + [match, start] = osm.match(/^after (\d{4}(?:-\d\d)?(?:-\d\d)?)$/) || []; + if (match) { + return `[${start}..]`; + } +} diff --git a/modules/util/index.js b/modules/util/index.js index 667127a94b..ce8e9b2340 100644 --- a/modules/util/index.js +++ b/modules/util/index.js @@ -14,7 +14,7 @@ export { utilArrayUniqBy } from './array'; export { utilAsyncMap } from './util'; export { utilCleanTags } from './clean_tags'; export { utilCombinedTags } from './util'; -export { utilDatesOverlap } from './util'; +export { utilDatesOverlap } from './date'; export { utilDeepMemberSelector } from './util'; export { utilDetect } from './detect'; export { utilDisplayName } from './util'; @@ -23,6 +23,7 @@ export { utilDisplayType } from './util'; export { utilDisplayLabel } from './util'; export { utilEntityRoot } from './util'; export { utilEditDistance } from './util'; +export { utilEDTFFromOSMDateString } from './date'; export { utilEntityAndDeepMemberIDs } from './util'; export { utilEntityOrMemberSelector } from './util'; export { utilEntityOrDeepMemberSelector } from './util'; @@ -34,7 +35,7 @@ export { utilGetSetValue } from './get_set_value'; export { utilHashcode } from './util'; export { utilHighlightEntities } from './util'; export { utilKeybinding } from './keybinding'; -export { utilNormalizeDateString } from './util'; +export { utilNormalizeDateString } from './date'; export { utilNoAuto } from './util'; export { utilObjectOmit } from './object'; export { utilCompareIDs } from './util'; diff --git a/modules/util/util.js b/modules/util/util.js index 13d30faf3c..19c3680f7d 100644 --- a/modules/util/util.js +++ b/modules/util/util.js @@ -4,6 +4,7 @@ import { fixRTLTextForSvg, rtlRegex } from './svg_paths_rtl_fix'; import { presetManager } from '../presets'; import { t, localizer } from '../core/localizer'; import { utilArrayUnion } from './array'; +import { utilNormalizeDateString } from './date'; import { utilDetect } from './detect'; import { geoExtent } from '../geo/extent'; @@ -682,110 +683,3 @@ export function utilCleanOsmString(val, maxChars) { // trim to the number of allowed characters return utilUnicodeCharsTruncated(val, maxChars); } -// Returns whether two date component arrays represent the same date. -// A date component array contains the full date, followed by year, month, day. -// Dates are only compared to the lowest common precision. -function isSameDate(date1, date2) { - if (date1[1] !== date2[1]) return false; - if (date1[2] && date2[2] && date1[2] !== date2[2]) return false; - if (date1[3] && date2[3] && date1[3] !== date2[3]) return false; - return true; -} - -function isBefore(date1, date2) { - if (date1[1] > date2[1]) return false; - if (date1[2] && date2[2] && date1[2] > date2[2]) return false; - if (date1[3] && date2[3] && date1[3] > date2[3]) return false; - return true; -} - -function isAfter(date1, date2) { - if (date1[1] < date2[1]) return false; - if (date1[2] && date2[2] && date1[2] < date2[2]) return false; - if (date1[3] && date2[3] && date1[3] < date2[3]) return false; - return true; -} - -/** - * Returns whether the two sets of tags overlap temporally. - * - * @param {object} tags1 Tags to compare. - * @param {object} tags2 Tags to compare. - * @param {boolean?} touchIsOverlap True to consider it an overlap if one date range ends at the same time that the other begins. - */ -export function utilDatesOverlap(tags1, tags2, touchIsOverlap) { - // If the feature has a valid start date but no valid end date, assume it - // starts at the beginning of time. - var dateRegex = /^(-?\d{1,4})(?:-(\d\d))?(?:-(\d\d))?$/; - var minDate = ['-9999', '-9999'], - maxDate = ['9999', '9999'], - start1 = (tags1.start_date || '').match(dateRegex) || minDate, - start2 = (tags2.start_date || '').match(dateRegex) || minDate, - end1 = (tags1.end_date || '').match(dateRegex) || maxDate, - end2 = (tags2.end_date || '').match(dateRegex) || maxDate; - - if (isSameDate(end1, start2) || isSameDate(end2, start1)) { - return touchIsOverlap === true; - } - - return ((isAfter(start1, start2) && isBefore(start1, end2)) || - (isAfter(start2, start1) && isBefore(start2, end1)) || - (isAfter(end1, start2) && isBefore(end1, end2)) || - (isAfter(end2, start1) && isBefore(end2, end1))); -} - -// Returns an object containing the given date string normalized as an ISO 8601 date string and parsed as a Date object. -// Date components are padded for compatibility with tagging conventions. -// Dates such as January 0, February 31, and Duodecember 1 are wrapped to make more sense. -export function utilNormalizeDateString(raw) { - if (!raw) return null; - - var date; - - // Enforce the same date formats supported by DateFunctions-plpgsql and decimaldate-python. - var dateRegex = /^(-)?(\d+)(?:-(\d\d?)(?:-(\d\d?))?)?$/; - var match = raw.match(dateRegex); - if (match !== null) { - // Manually construct a Date. - // Passing the string directly into the Date constructor would throw an error on negative years. - date = new Date(0); - date.setUTCFullYear(parseInt((match[1] || '') + match[2], 10)); - if (match[3]) date.setUTCMonth(parseInt(match[3], 10) - 1); // 0-based - if (match[4]) date.setUTCDate(parseInt(match[4], 10)); - } else { - // Fall back on whatever the browser can parse into a date. - date = new Date(raw); - try { - date.toISOString(); - } catch (exc) { - return null; - } - } - - // Reconstruct the date string. - // Avoid Date.toISOString() because it has fixed precision and excessively pads negative years. - var normalized = ''; - if (match !== null && date.getUTCFullYear() < 0) { - var absYear = Math.abs(date.getUTCFullYear()); - normalized += '-' + String(absYear).padStart(4, '0'); - } else { - normalized += String(date.getUTCFullYear()).padStart(4, '0'); - } - if (match === null || match[3]) { - normalized += '-' + String(date.getUTCMonth() + 1).padStart(2, '0'); - } - if (match === null || match[4]) { - normalized += '-' + String(date.getUTCDate()).padStart(2, '0'); - } - return { - date: date, - value: normalized, - localeOptions: { - year: 'numeric', - era: date.getUTCFullYear() < 1 ? 'short' : undefined, - month: (match === null || match[3]) ? 'long' : undefined, - day: (match === null || match[4]) ? 'numeric' : undefined, - timeZone: 'UTC' - } - }; -} diff --git a/modules/validations/invalid_format.js b/modules/validations/invalid_format.js index 50e596791c..eaf7c292b4 100644 --- a/modules/validations/invalid_format.js +++ b/modules/validations/invalid_format.js @@ -1,6 +1,6 @@ import { actionChangeTags } from '../actions/change_tags'; import { localizer, t } from '../core/localizer'; -import { utilDisplayLabel, utilNormalizeDateString } from '../util'; +import { utilDisplayLabel, utilNormalizeDateString, utilEDTFFromOSMDateString } from '../util'; import { validationIssue, validationIssueFix } from '../core/validation'; import * as edtf from 'edtf'; @@ -38,21 +38,47 @@ export function validationFormatting() { hash: key + entity.tags[key], dynamicFixes: function() { var fixes = []; + + let alternatives = []; if (normalized !== null) { - var localeDateString = normalized.date.toLocaleDateString(localizer.languageCode(), normalized.localeOptions); - fixes.push(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')); - } - })); + let label = normalized.date.toLocaleDateString(localizer.languageCode(), normalized.localeOptions); + alternatives.push({ + date: normalized.value, + label: label || normalized.value, + }); + } + let edtfFromOSM = utilEDTFFromOSMDateString(entity.tags[key]); + if (edtfFromOSM) { + let label; + try { + label = edtf.default(edtfFromOSM).format(localizer.languageCode()); + } catch (e) { + label = edtfFromOSM; + } + alternatives.push({ + edtf: edtfFromOSM, + label: label, + }); } + + fixes.push(...alternatives.map(alt => new validationIssueFix({ + title: t.append('issues.fix.reformat_date.title', { date: alt.label }), + onClick: function(context) { + context.perform(function(graph) { + var entityInGraph = graph.hasEntity(entity.id); + if (!entityInGraph) return graph; + var newTags = Object.assign({}, entityInGraph.tags); + if (alt.date) { + newTags[key] = alt.date; + } else { + delete newTags[key]; + } + newTags[key + ':edtf'] = alt.edtf; + 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'), @@ -66,6 +92,7 @@ export function validationFormatting() { }, t('issues.fix.remove_tag.annotation')); } })); + return fixes; } })); diff --git a/test/spec/util/date.js b/test/spec/util/date.js new file mode 100644 index 0000000000..2e654de3ac --- /dev/null +++ b/test/spec/util/date.js @@ -0,0 +1,97 @@ +describe('iD.date', function() { + describe('utilDatesOverlap', function() { + it('compares years', function() { + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, + {start_date: '1970', end_date: '2000'})).to.eql(true); + expect(iD.utilDatesOverlap({start_date: '2000', end_date: '2038'}, + {start_date: '1970', end_date: '2000'})).to.eql(false); + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, + {start_date: '2000-01', end_date: '2038'})).to.eql(false); + }); + it('compares full dates', function() { + expect(iD.utilDatesOverlap({start_date: '1970-01-01', end_date: '2000-01-01'}, + {start_date: '1970-01-01', end_date: '2000-01-01'})).to.eql(true); + expect(iD.utilDatesOverlap({start_date: '1970-01-01', end_date: '2000-01-01'}, + {start_date: '2000-01-01', end_date: '2038-01-01'})).to.eql(false); + expect(iD.utilDatesOverlap({start_date: '2000-01-01', end_date: '2038-01-01'}, + {start_date: '1970-01-01', end_date: '2000-01-01'})).to.eql(false); + }); + it('treats touches as overlaps', function() { + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, + {start_date: '2001', end_date: '2038'})).to.eql(false); + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, + {start_date: '2001', end_date: '2038'}, true)).to.eql(false); + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, + {start_date: '2000', end_date: '2038'})).to.eql(false); + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, + {start_date: '2000', end_date: '2038'}, true)).to.eql(true); + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000-01'}, + {start_date: '2000', end_date: '2038'})).to.eql(false); + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000-01'}, + {start_date: '2000', end_date: '2038'}, true)).to.eql(true); + expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, + {start_date: '2000-01', end_date: '2038'}, true)).to.eql(true); + }); + }); + + describe('utilNormalizeDateString', function() { + it('pads dates', function() { + expect(iD.utilNormalizeDateString('1970-01-01').value).to.eql('1970-01-01'); + expect(iD.utilNormalizeDateString('1970-01-1').value).to.eql('1970-01-01'); + expect(iD.utilNormalizeDateString('1970-1-01').value).to.eql('1970-01-01'); + expect(iD.utilNormalizeDateString('-1-1-01').value).to.eql('-0001-01-01'); + expect(iD.utilNormalizeDateString('123').value).to.eql('0123'); + expect(iD.utilNormalizeDateString('-4003').value).to.eql('-4003'); // beyond displayable year range but still valid + expect(iD.utilNormalizeDateString('31337').value).to.eql('31337'); + expect(iD.utilNormalizeDateString('-31337').value).to.eql('-31337'); + }); + it('wraps dates', function() { + expect(iD.utilNormalizeDateString('1970-01-00').value).to.eql('1969-12-31'); + expect(iD.utilNormalizeDateString('1970-12-32').value).to.eql('1971-01-01'); + expect(iD.utilNormalizeDateString('1970-02-29').value).to.eql('1970-03-01'); + expect(iD.utilNormalizeDateString('1970-00').value).to.eql('1969-12'); + expect(iD.utilNormalizeDateString('1970-23').value).to.eql('1971-11'); // no EDTF for now + }); + it('rejects malformed dates', function() { + expect(iD.utilNormalizeDateString('1970-01--1')).to.eql(null); + expect(iD.utilNormalizeDateString('197X')).to.eql(null); // no EDTF for now + }); + it('respects the original precision', function() { + expect(iD.utilNormalizeDateString('123').value).to.eql('0123'); + expect(iD.utilNormalizeDateString('2000-06').value).to.eql('2000-06'); + expect(iD.utilNormalizeDateString('2000-06').localeOptions.month).to.eql('long'); + expect(iD.utilNormalizeDateString('2000-06').localeOptions.day).to.eql(undefined); + }); + it('displays era before common era', function() { + expect(iD.utilNormalizeDateString('1').localeOptions.era).to.eql(undefined); + expect(iD.utilNormalizeDateString('0').localeOptions.era).to.eql('short'); + expect(iD.utilNormalizeDateString('-1').localeOptions.era).to.eql('short'); + }); + }); + + describe('utilEDTFFromOSMDateString', function () { + it('converts approximations', function () { + expect(iD.utilEDTFFromOSMDateString('~1855')).to.eql('1855~'); + expect(iD.utilEDTFFromOSMDateString('1860s')).to.eql('186X'); + expect(iD.utilEDTFFromOSMDateString('1800s')).to.eql('180X'); + expect(iD.utilEDTFFromOSMDateString('~1940s')).to.eql('194X~'); + expect(iD.utilEDTFFromOSMDateString('C18')).to.eql('17XX'); + expect(iD.utilEDTFFromOSMDateString('C4')).to.eql('03XX'); + expect(iD.utilEDTFFromOSMDateString('~C13')).to.eql('12XX~'); + }); + it('converts early, late, and mid dates', function () { + expect(iD.utilEDTFFromOSMDateString('late 1920s')).to.eql('1927~/1929~'); + expect(iD.utilEDTFFromOSMDateString('mid C14')).to.eql('1330~/1370~'); + }); + it('converts unbounded dates', function () { + expect(iD.utilEDTFFromOSMDateString('before 1823')).to.eql('[..1823]'); + expect(iD.utilEDTFFromOSMDateString('before 1910-01-20')).to.eql('[..1910-01-20]'); + expect(iD.utilEDTFFromOSMDateString('after 1500')).to.eql('[1500..]'); + }); + it('converts date ranges', function () { + expect(iD.utilEDTFFromOSMDateString('1914..1918')).to.eql('[1914..1918]'); + expect(iD.utilEDTFFromOSMDateString('2008-08-08..2008-08-24')).to.eql('[2008-08-08..2008-08-24]'); + expect(iD.utilEDTFFromOSMDateString('mid C17..late C17')).to.eql('1630~/1699~'); + }); + }); +}); diff --git a/test/spec/util/util.js b/test/spec/util/util.js index 9cd906b699..6a2ab82d78 100644 --- a/test/spec/util/util.js +++ b/test/spec/util/util.js @@ -315,74 +315,4 @@ describe('iD.util', function() { expect(iD.utilOldestID(['z', 'a', 'A', 'Z'])).to.eql('z'); }); }); - - describe('utilDatesOverlap', function() { - it('compares years', function() { - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, - {start_date: '1970', end_date: '2000'})).to.eql(true); - expect(iD.utilDatesOverlap({start_date: '2000', end_date: '2038'}, - {start_date: '1970', end_date: '2000'})).to.eql(false); - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, - {start_date: '2000-01', end_date: '2038'})).to.eql(false); - }); - it('compares full dates', function() { - expect(iD.utilDatesOverlap({start_date: '1970-01-01', end_date: '2000-01-01'}, - {start_date: '1970-01-01', end_date: '2000-01-01'})).to.eql(true); - expect(iD.utilDatesOverlap({start_date: '1970-01-01', end_date: '2000-01-01'}, - {start_date: '2000-01-01', end_date: '2038-01-01'})).to.eql(false); - expect(iD.utilDatesOverlap({start_date: '2000-01-01', end_date: '2038-01-01'}, - {start_date: '1970-01-01', end_date: '2000-01-01'})).to.eql(false); - }); - it('treats touches as overlaps', function() { - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, - {start_date: '2001', end_date: '2038'})).to.eql(false); - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, - {start_date: '2001', end_date: '2038'}, true)).to.eql(false); - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, - {start_date: '2000', end_date: '2038'})).to.eql(false); - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, - {start_date: '2000', end_date: '2038'}, true)).to.eql(true); - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000-01'}, - {start_date: '2000', end_date: '2038'})).to.eql(false); - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000-01'}, - {start_date: '2000', end_date: '2038'}, true)).to.eql(true); - expect(iD.utilDatesOverlap({start_date: '1970', end_date: '2000'}, - {start_date: '2000-01', end_date: '2038'}, true)).to.eql(true); - }); - }); - - describe('utilNormalizeDateString', function() { - it('pads dates', function() { - expect(iD.utilNormalizeDateString('1970-01-01').value).to.eql('1970-01-01'); - expect(iD.utilNormalizeDateString('1970-01-1').value).to.eql('1970-01-01'); - expect(iD.utilNormalizeDateString('1970-1-01').value).to.eql('1970-01-01'); - expect(iD.utilNormalizeDateString('-1-1-01').value).to.eql('-0001-01-01'); - expect(iD.utilNormalizeDateString('123').value).to.eql('0123'); - expect(iD.utilNormalizeDateString('-4003').value).to.eql('-4003'); // beyond displayable year range but still valid - expect(iD.utilNormalizeDateString('31337').value).to.eql('31337'); - expect(iD.utilNormalizeDateString('-31337').value).to.eql('-31337'); - }); - it('wraps dates', function() { - expect(iD.utilNormalizeDateString('1970-01-00').value).to.eql('1969-12-31'); - expect(iD.utilNormalizeDateString('1970-12-32').value).to.eql('1971-01-01'); - expect(iD.utilNormalizeDateString('1970-02-29').value).to.eql('1970-03-01'); - expect(iD.utilNormalizeDateString('1970-00').value).to.eql('1969-12'); - expect(iD.utilNormalizeDateString('1970-23').value).to.eql('1971-11'); // no EDTF for now - }); - it('rejects malformed dates', function() { - expect(iD.utilNormalizeDateString('1970-01--1')).to.eql(null); - expect(iD.utilNormalizeDateString('197X')).to.eql(null); // no EDTF for now - }); - it('respects the original precision', function() { - expect(iD.utilNormalizeDateString('123').value).to.eql('0123'); - expect(iD.utilNormalizeDateString('2000-06').value).to.eql('2000-06'); - expect(iD.utilNormalizeDateString('2000-06').localeOptions.month).to.eql('long'); - expect(iD.utilNormalizeDateString('2000-06').localeOptions.day).to.eql(undefined); - }); - it('displays era before common era', function() { - expect(iD.utilNormalizeDateString('1').localeOptions.era).to.eql(undefined); - expect(iD.utilNormalizeDateString('0').localeOptions.era).to.eql('short'); - expect(iD.utilNormalizeDateString('-1').localeOptions.era).to.eql('short'); - }); - }); }); diff --git a/test/spec/validations/invalid_format.js b/test/spec/validations/invalid_format.js index da7547a0b6..c16ade8dec 100644 --- a/test/spec/validations/invalid_format.js +++ b/test/spec/validations/invalid_format.js @@ -50,4 +50,14 @@ describe('iD.validations.invalid_format', function () { expect(issue.entityIds).to.have.lengthOf(1); expect(issue.entityIds[0]).to.eql('n-1'); }); + + it('flags way with OSM-style date tag', function() { + createNode({ natural: 'tree', name: 'The Tree That Owns Itself', 'start_date': 'before C20', 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'); + }); });