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> = {