diff --git a/root/static/scripts/alias/AliasEditForm.js b/root/static/scripts/alias/AliasEditForm.js index 08ecb65b3b0..3f0795ffb7e 100644 --- a/root/static/scripts/alias/AliasEditForm.js +++ b/root/static/scripts/alias/AliasEditForm.js @@ -17,6 +17,7 @@ import isBlank from '../common/utility/isBlank.js'; import DateRangeFieldset, { type ActionT as DateRangeFieldsetActionT, runReducer as runDateRangeFieldsetReducer, + setInitialStateOnForm as setInitialDateRangeFieldsetStateOnForm, } from '../edit/components/DateRangeFieldset.js'; import EnterEdit from '../edit/components/EnterEdit.js'; import EnterEditNote from '../edit/components/EnterEditNote.js'; @@ -96,7 +97,7 @@ const blankDatePeriod = { function createInitialState(form: AliasEditFormT, searchHintType: number) { return { - form, + form: setInitialDateRangeFieldsetStateOnForm(form), guessCaseOptions: createGuessCaseOptionsState(), isEnded: form.field.period.field.ended.value, isGuessCaseOptionsOpen: false, diff --git a/root/static/scripts/common/utility/formatDate.js b/root/static/scripts/common/utility/formatDate.js index 9ebfc072746..663f06a70c3 100644 --- a/root/static/scripts/common/utility/formatDate.js +++ b/root/static/scripts/common/utility/formatDate.js @@ -11,30 +11,34 @@ import ko from 'knockout'; import {fixedWidthInteger} from './strings.js'; -function formatDate(date: ?PartialDateT): string { +function formatDate(date: ?{ + +day?: ?StrOrNum, + +month?: ?StrOrNum, + +year?: ?StrOrNum, +}): string { if (!date) { return ''; } - const y: number | null = ko.unwrap(date.year ?? null); - const m: number | null = ko.unwrap(date.month ?? null); - const d: number | null = ko.unwrap(date.day ?? null); + const y: StrOrNum | null = ko.unwrap(date.year ?? null); + const m: StrOrNum | null = ko.unwrap(date.month ?? null); + const d: StrOrNum | null = ko.unwrap(date.day ?? null); let result = ''; if (nonEmpty(y)) { result += fixedWidthInteger(y, 4); - } else if (m != null || d != null) { + } else if (nonEmpty(m) || nonEmpty(d)) { result = '????'; } - if (m != null) { + if (nonEmpty(m)) { result += '-' + fixedWidthInteger(m, 2); - } else if (d != null) { + } else if (nonEmpty(d)) { result += '-??'; } - if (d != null) { + if (nonEmpty(d)) { result += '-' + fixedWidthInteger(d, 2); } diff --git a/root/static/scripts/common/utility/isDateEmpty.js b/root/static/scripts/common/utility/isDateEmpty.js index 076daca20e0..7f61f954164 100644 --- a/root/static/scripts/common/utility/isDateEmpty.js +++ b/root/static/scripts/common/utility/isDateEmpty.js @@ -7,6 +7,24 @@ * later version: http://www.gnu.org/licenses/gpl-2.0.txt */ +import {type Observable as KnockoutObservable} from 'knockout'; + +declare type PartialDateObservablesT = { + +day: KnockoutObservable, + +month: KnockoutObservable, + +year: KnockoutObservable, +}; + +export function isDateObservableEmpty( + date: PartialDateObservablesT, +): boolean { + return !( + nonEmpty(date.year()) || + nonEmpty(date.month()) || + nonEmpty(date.day()) + ); +} + export function isDateNonEmpty( date: ?PartialDateT | ?PartialDateStringsT, ): implies date is PartialDateT | PartialDateStringsT { diff --git a/root/static/scripts/common/utility/parseDate.js b/root/static/scripts/common/utility/parseDate.js index 46821be926e..afa052166eb 100644 --- a/root/static/scripts/common/utility/parseDate.js +++ b/root/static/scripts/common/utility/parseDate.js @@ -13,13 +13,13 @@ const dateRegex = /^(\d{4}|\?{4}|-)(?:-(\d{2}|\?{2}|-)(?:-(\d{2}|\?{2}|-))?)?$/; function parseDate(str: string): PartialDateT { const match = str.match(dateRegex) || []; - /* eslint-disable sort-keys */ return { + /* eslint-disable sort-keys */ year: parseIntegerOrNull(match[1]), month: parseIntegerOrNull(match[2]), day: parseIntegerOrNull(match[3]), + /* eslint-enable sort-keys */ }; - /* eslint-enable sort-keys */ } export default parseDate; diff --git a/root/static/scripts/common/utility/parseNaturalDate.js b/root/static/scripts/common/utility/parseNaturalDate.js new file mode 100644 index 00000000000..dadc87a0b74 --- /dev/null +++ b/root/static/scripts/common/utility/parseNaturalDate.js @@ -0,0 +1,82 @@ +/* + * @flow strict + * Copyright (C) 2022 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + + +const ymdRegex = /^[^\w?]*([0-9]{4}|\?{4})(?:[^\w?]+(0?[1-9]|1[0-2]|\?{2})(?:[^\w?]+(0?[1-9]|[12][0-9]|3[01]|\?{2}))?)?[^\w?]*$/; +const cjkRegex = /^[^\w?]*([0-9]{2}|[0-9]{4})(?:(?:\u5E74|\uB144[^\w?]?)(0?[1-9]|1[0-2])(?:(?:\u6708|\uC6D4[^\w?]*?)(0?[1-9]|[12][0-9]|3[01])(?:\u65E5|\uC77C)?)?)?[^\w?]*$/; + +function cleanDateString( + str: string, +): string { + let cleanedString = str; + + // Clean fullwidth digits to standard digits + cleanedString = cleanedString.replace( + /[0-9-]/g, + function (fullwidthDigit) { + return String.fromCharCode( + fullwidthDigit.charCodeAt(0) - + ('0'.charCodeAt(0) - '0'.charCodeAt(0)), + ); + }, + ); + + // See https://web.archive.org/web/20220330023649/https://reference.discogs.com/wiki/japanese-release-dates + const japaneseYearCodes = { + /* eslint-disable sort-keys */ + N: '1984', + I: '1985', + H: '1986', + O: '1987', + R: '1988', + E: '1989', + C: '1990', + D: '1991', + K: '1992', + L: '1993', + /* eslint-enable sort-keys */ + }; + cleanedString = cleanedString.replace( + /([NIHORECDKL])-([0-9]{1,2}-[0-9]{1,2})/, + function (match, year, date) { + return japaneseYearCodes[year] + '-' + date; + }, + ); + + // RoC year numbering - http://en.wikipedia.org/wiki/Minguo_calendar + cleanedString = cleanedString.replace( + /民國([0-9]{1,3})/, + function (match, year) { + return String(parseInt(year, 10) + 1911); + }, + ); + + return cleanedString; +} + +export default function parseNaturalDate( + str: string, +): PartialDateStringsT { + const cleanedString = cleanDateString(str); + + const match = cleanedString.match(cjkRegex) || + cleanedString.match(ymdRegex) || + []; + + const year = match[1] === '????' ? '' : match[1]; + const month = match[2] === '??' ? '' : match[2]; + const day = match[3] === '??' ? '' : match[3]; + return { + /* eslint-disable sort-keys */ + year: year || '', + month: month || '', + day: day || '', + /* eslint-enable sort-keys */ + }; +} diff --git a/root/static/scripts/edit/components/DateRangeFieldset.js b/root/static/scripts/edit/components/DateRangeFieldset.js index d38af0605c4..e8ed328a6e2 100644 --- a/root/static/scripts/edit/components/DateRangeFieldset.js +++ b/root/static/scripts/edit/components/DateRangeFieldset.js @@ -22,6 +22,9 @@ import FormRowPartialDate, { type ActionT as FormRowPartialDateActionT, runReducer as runFormRowPartialDateReducer, } from './FormRowPartialDate.js'; +import { + createInitialState as createPartialDateInputState, +} from './PartialDateInput.js'; /* eslint-disable ft-flow/sort-keys */ export type ActionT = @@ -33,6 +36,11 @@ export type ActionT = export type StateT = DatePeriodFieldT; +type FormWithDateRangeT = FormT<{ + +period: DatePeriodFieldT, + ... +}>; + export function partialDateFromField( compoundField: PartialDateFieldT, ): PartialDateT { @@ -44,6 +52,26 @@ export function partialDateFromField( }; } +export function setInitialStateOnForm(form: T): T { + const formCtx = mutate(form); + // $FlowExpectedError[incompatible-type] + const periodCtx = formCtx.get('field', 'period'); + periodCtx.set(createInitialState(periodCtx.read())); + return formCtx.final(); +} + + +export function createInitialState( + field: StateT, +): StateT { + const fieldCtx = mutate(field); + const beginDateField = fieldCtx.get('field', 'begin_date'); + beginDateField.set(createPartialDateInputState(beginDateField.read())); + const endDateField = fieldCtx.get('field', 'end_date'); + endDateField.set(createPartialDateInputState(endDateField.read())); + return fieldCtx.final(); +} + function validateDatePeriod(stateCtx: CowContext) { const state = stateCtx.read(); const beginDateField = state.field.begin_date; @@ -77,6 +105,9 @@ function runDateFieldReducer( {type: 'set-date', ...} => { validateDatePeriod(state); } + {type: 'set-parsed-date', ...} => { + // Do nothing + } {type: 'show-pending-errors'} => { /* * Changing the begin date may produce on an error on the end date @@ -173,6 +204,10 @@ component _DateRangeFieldset( Partial dates such as YYYY-MM or just YYYY are OK, or you can omit the date entirely.`)}

+

+ {l(`You can also enter a full date string into the parsing field + rather than entering year, month and day separately.`)} +

, date: PartialDateStringsT) { + const newYear = date.year; + const newMonth = date.month; + const newDay = date.day; + if (newYear != null) { + dateCtx.set('field', 'year', 'value', newYear); + } + if (newMonth != null) { + dateCtx.set('field', 'month', 'value', newMonth); + } + if (newDay != null) { + dateCtx.set('field', 'day', 'value', newDay); + } + validateDate(dateCtx); +} + function validateDate(dateCtx: CowContext) { const date = dateCtx.read(); const year = String(date.field.year.value ?? ''); @@ -60,20 +96,19 @@ export function runReducer( {type: 'show-pending-errors'} => { applyPendingErrors(state); } + {type: 'set-parsed-date', const date} => { + const parsedDate = parseNaturalDate(date); + updateDate(state, parsedDate); + state.set('formattedDate', date); + } {type: 'set-date', const date} => { - const newYear = date.year; - const newMonth = date.month; - const newDay = date.day; - if (newYear != null) { - state.set('field', 'year', 'value', newYear); + updateDate(state, date); + const formattedDate = formatParserDate(state.read()); + if (nonEmpty(formattedDate)) { + state.set('formattedDate', formatParserDate(state.read())); + } else { + state.set('formattedDate', formatParserDate(state.read())); } - if (newMonth != null) { - state.set('field', 'month', 'value', newMonth); - } - if (newDay != null) { - state.set('field', 'day', 'value', newDay); - } - validateDate(state); } } } @@ -85,6 +120,12 @@ type DatePartPropsT = { value?: StrOrNum, }; +type DateParserPropsT = { + onBlur?: () => void, + onChange?: (SyntheticEvent) => void, + value?: string, +}; + component PartialDateInput( disabled: boolean = false, field: PartialDateFieldT, @@ -94,6 +135,7 @@ component PartialDateInput( const yearProps: DatePartPropsT = {}; const monthProps: DatePartPropsT = {}; const dayProps: DatePartPropsT = {}; + const parserProps: DateParserPropsT = {}; if (controlledProps.uncontrolled /*:: === true */) { yearProps.defaultValue = field.field.year.value; @@ -118,6 +160,7 @@ component PartialDateInput( yearProps.onBlur = handleBlur; monthProps.onBlur = handleBlur; dayProps.onBlur = handleBlur; + parserProps.onBlur = handleBlur; yearProps.onChange = (event) => handleDateChange( event, @@ -132,9 +175,17 @@ component PartialDateInput( 'day', ); + parserProps.onChange = (event) => { + controlledProps.dispatch({ + date: event.currentTarget.value, + type: 'set-parsed-date', + }); + }; + yearProps.value = field.field.year.value ?? ''; monthProps.value = field.field.month.value ?? ''; dayProps.value = field.field.day.value ?? ''; + parserProps.value = field.formattedDate ?? ''; } return ( @@ -175,6 +226,22 @@ component PartialDateInput( type="text" {...dayProps} /> + {controlledProps.uncontrolled /*:: === true */ ? null : ( + <> + {' '} + + + )} ); } diff --git a/root/static/scripts/event/components/EventEditForm.js b/root/static/scripts/event/components/EventEditForm.js index b2b3aec0941..8b783a2743b 100644 --- a/root/static/scripts/event/components/EventEditForm.js +++ b/root/static/scripts/event/components/EventEditForm.js @@ -18,6 +18,7 @@ import isBlank from '../../common/utility/isBlank.js'; import DateRangeFieldset, { type ActionT as DateRangeFieldsetActionT, runReducer as runDateRangeFieldsetReducer, + setInitialStateOnForm as setInitialDateRangeFieldsetStateOnForm, } from '../../edit/components/DateRangeFieldset.js'; import EnterEdit from '../../edit/components/EnterEdit.js'; import EnterEditNote from '../../edit/components/EnterEditNote.js'; @@ -67,7 +68,7 @@ type StateT = { function createInitialState(form: EventFormT) { return { - form, + form: setInitialDateRangeFieldsetStateOnForm(form), guessCaseOptions: createGuessCaseOptionsState(), isGuessCaseOptionsOpen: false, showTypeBubble: false, diff --git a/root/static/scripts/relationship-editor/components/DialogDatePeriod.js b/root/static/scripts/relationship-editor/components/DialogDatePeriod.js index ba4a64a41d1..1a05b488d30 100644 --- a/root/static/scripts/relationship-editor/components/DialogDatePeriod.js +++ b/root/static/scripts/relationship-editor/components/DialogDatePeriod.js @@ -21,7 +21,8 @@ import FieldErrors, { FieldErrorsList, } from '../../edit/components/FieldErrors.js'; import FormRowCheckbox from '../../edit/components/FormRowCheckbox.js'; -import PartialDateInput from '../../edit/components/PartialDateInput.js'; +import PartialDateInput, {formatParserDate} + from '../../edit/components/PartialDateInput.js'; import useDateRangeFieldset from '../../edit/hooks/useDateRangeFieldset.js'; import { createCompoundField, @@ -40,41 +41,51 @@ export function createInitialState( ended, } = datePeriod; + const beginDateField = createCompoundField( + 'period.begin_date', + { + day: createField( + 'period.begin_date.day', + (beginDate?.day ?? null), + ), + month: createField( + 'period.begin_date.month', + (beginDate?.month ?? null), + ), + year: createField( + 'period.begin_date.year', + (beginDate?.year ?? null), + ), + }, + ); + + const endDateField = createCompoundField( + 'period.end_date', + { + day: createField( + 'period.end_date.day', + (endDate?.day ?? null), + ), + month: createField( + 'period.end_date.month', + (endDate?.month ?? null), + ), + year: createField( + 'period.end_date.year', + (endDate?.year ?? null), + ), + }, + ); + const field = createCompoundField('period', { - begin_date: createCompoundField( - 'period.begin_date', - { - day: createField( - 'period.begin_date.day', - (beginDate?.day ?? null), - ), - month: createField( - 'period.begin_date.month', - (beginDate?.month ?? null), - ), - year: createField( - 'period.begin_date.year', - (beginDate?.year ?? null), - ), - }, - ), - end_date: createCompoundField( - 'period.end_date', - { - day: createField( - 'period.end_date.day', - (endDate?.day ?? null), - ), - month: createField( - 'period.end_date.month', - (endDate?.month ?? null), - ), - year: createField( - 'period.end_date.year', - (endDate?.year ?? null), - ), - }, - ), + begin_date: { + ...beginDateField, + formattedDate: formatParserDate(beginDateField), + }, + end_date: { + ...endDateField, + formattedDate: formatParserDate(endDateField), + }, ended: createField('period.ended', ended), }); diff --git a/root/static/scripts/tests/index-web.js b/root/static/scripts/tests/index-web.js index 241205d556d..89bae87f8f5 100644 --- a/root/static/scripts/tests/index-web.js +++ b/root/static/scripts/tests/index-web.js @@ -62,6 +62,7 @@ require('./utility/isShortenedUrl.js'); require('./utility/isSpecialPurpose.js'); require('./utility/natatime.js'); require('./utility/parseDate.js'); +require('./utility/parseNaturalDate.js'); require('./utility/primaryAreaCode.js'); require('./utility/relationshipDateText.js'); require('./utility/sanitizedEditor.js'); diff --git a/root/static/scripts/tests/utility/parseNaturalDate.js b/root/static/scripts/tests/utility/parseNaturalDate.js new file mode 100644 index 00000000000..bc055adde9a --- /dev/null +++ b/root/static/scripts/tests/utility/parseNaturalDate.js @@ -0,0 +1,70 @@ +/* + * @flow strict + * Copyright (C) 2023 MetaBrainz Foundation + * + * This file is part of MusicBrainz, the open internet music database, + * and is licensed under the GPL version 2, or (at your option) any + * later version: http://www.gnu.org/licenses/gpl-2.0.txt + */ + +import test from 'tape'; + +import parseNaturalDate from '../../common/utility/parseNaturalDate.js'; + +test('parseNaturalDate', function (t) { + t.plan(21); + + /* eslint-disable sort-keys */ + const parseDateTests = [ + // Nothing + {date: '', expected: {year: '', month: '', day: ''}}, + + // Y-M-D + {date: '0000', expected: {year: '0000', month: '', day: ''}}, + {date: '1999-01-02', expected: {year: '1999', month: '01', day: '02'}}, + {date: '1999-01', expected: {year: '1999', month: '01', day: ''}}, + {date: '1999', expected: {year: '1999', month: '', day: ''}}, + {date: '1999-??-22', expected: {year: '1999', month: '', day: '22'}}, + {date: '????-??-22', expected: {year: '', month: '', day: '22'}}, + {date: '????-01-??', expected: {year: '', month: '01', day: ''}}, + + // Y M D + {date: '1999 01 02', expected: {year: '1999', month: '01', day: '02'}}, + {date: '1999 01', expected: {year: '1999', month: '01', day: ''}}, + {date: '1999 ?? 22', expected: {year: '1999', month: '', day: '22'}}, + {date: '???? ?? 22', expected: {year: '', month: '', day: '22'}}, + {date: '???? 01 ??', expected: {year: '', month: '01', day: ''}}, + + // Fullwidth numbers + { + date: '1999-01-02', + expected: {year: '1999', month: '01', day: '02'}, + }, + { + date: '1999-01-02', + expected: {year: '1999', month: '01', day: '02'}, + }, + { + date: '1999 01 02', + expected: {year: '1999', month: '01', day: '02'}, + }, + + // Japanese year codes + {date: 'O-2-25', expected: {year: '1987', month: '2', day: '25'}}, + {date: 'D-12-10', expected: {year: '1991', month: '12', day: '10'}}, + + // CJK dates + {date: '2022年4月29日', expected: {year: '2022', month: '4', day: '29'}}, + {date: '2021년2월1일', expected: {year: '2021', month: '2', day: '1'}}, + { + date: '民國99年12月31日', + expected: {year: '2010', month: '12', day: '31'}, + }, + ]; + /* eslint-enable sort-keys */ + + for (const test of parseDateTests) { + const result = parseNaturalDate(test.date); + t.deepEqual(result, test.expected, test.date); + } +}); diff --git a/root/static/styles/forms.less b/root/static/styles/forms.less index 13d2b311341..26f99f72c9f 100644 --- a/root/static/styles/forms.less +++ b/root/static/styles/forms.less @@ -459,6 +459,7 @@ div.half-width fieldset span.partial-date { &.partial-date-year { width: 4em; } &.partial-date-month { width: 2.5em; } &.partial-date-day { width: 2.5em; } + &.partial-date-parser { width: 12em; } } } diff --git a/root/types/formcomponents.js b/root/types/formcomponents.js index 6cff273d596..eb741de50b6 100644 --- a/root/types/formcomponents.js +++ b/root/types/formcomponents.js @@ -128,10 +128,13 @@ declare type OptionTreeT<+T> = { +parent_id: number | null, }; -declare type PartialDateFieldT = CompoundFieldT<{ - +day: FieldT, - +month: FieldT, - +year: FieldT, +declare type PartialDateFieldT = $ReadOnly<{ + ...CompoundFieldT<{ + +day: FieldT, + +month: FieldT, + +year: FieldT, + }>, + +formattedDate?: string, }>; declare type RepeatableFieldT<+F> = {