Skip to content

Fix Date Parser Any Parsing Two-Digit Number as Year #4242

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 83 additions & 0 deletions src/app/features/tasks/short-syntax.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ShowSubTasksMode, TaskCopy } from './task.model';
import { shortSyntax } from './short-syntax';
import { getWorklogStr } from '../../util/get-work-log-str';
import {
MONTH_SHORT_NAMES,
oneDayInMilliseconds,
} from '../../util/month-time-conversion';
import { Tag } from '../tag/tag.model';
import { DEFAULT_TAG } from '../tag/tag.const';
import { Project } from '../project/project.model';
Expand Down Expand Up @@ -111,6 +115,28 @@ const checkIfCorrectDateAndTime = (
return isDayCorrect && isHourCorrect && isMinuteCorrect;
};

const checkIfCorrectDateMonthAndYear = (
timestamp: number,
givenDate: number,
givenMonth: number,
givenYear: number,
hour?: number,
minute?: number,
): boolean => {
const date = new Date(timestamp);
const correctDateMonthYear =
date.getDate() === givenDate &&
date.getMonth() + 1 === givenMonth &&
date.getFullYear() === givenYear;
if (!hour) {
return correctDateMonthYear;
}
if (!minute) {
return correctDateMonthYear && date.getHours() === hour;
}
return correctDateMonthYear && date.getHours() === hour && date.getMinutes() === minute;
};

describe('shortSyntax', () => {
it('should ignore for no short syntax', () => {
const r = shortSyntax(TASK, CONFIG);
Expand Down Expand Up @@ -955,4 +981,61 @@ describe('shortSyntax', () => {
expect(r).toEqual(undefined);
});
});

// This group of tests address Chrono's parsing the format "<date> <month> <yy}>" as year
// This will cause unintended parsing result when the date syntax is used together with the time estimate syntax
// https://github.com/johannesjo/super-productivity/issues/4194
// The focus of this test group will be the ability of the parser to get the correct year and time estimate
describe('should not parse time estimate syntax as year', () => {
const today = new Date();
const minuteEstimate = 90;

it('should correctly parse year and time estimate when the input date only has month and day of the month', () => {
const tomorrow = new Date(today.getTime() + oneDayInMilliseconds);
const inputMonth = tomorrow.getMonth() + 1;
const inputMonthName = MONTH_SHORT_NAMES[tomorrow.getMonth()];
const inputDayOfTheMonth = tomorrow.getDate();
const t = {
...TASK,
title: `Test @${inputMonthName} ${inputDayOfTheMonth} ${minuteEstimate}m`,
};
const parsedTaskInfo = shortSyntax(t, CONFIG, []);
const taskChanges = parsedTaskInfo?.taskChanges;
const plannedAt = taskChanges?.plannedAt as number;
expect(
checkIfCorrectDateMonthAndYear(
plannedAt,
inputDayOfTheMonth,
inputMonth,
tomorrow.getFullYear(),
),
).toBeTrue();
expect(taskChanges?.timeEstimate).toEqual(minuteEstimate * 60 * 1000);
});

it('should correctly parse year and time estimate when the input date contains month, day of the month and time', () => {
const time = '4pm';
const tomorrow = new Date(today.getTime() + oneDayInMilliseconds);
const inputMonth = tomorrow.getMonth() + 1;
const inputMonthName = MONTH_SHORT_NAMES[tomorrow.getMonth()];
const inputDayOfTheMonth = tomorrow.getDate();
const t = {
...TASK,
title: `Test @${inputMonthName} ${inputDayOfTheMonth} ${time} ${minuteEstimate}m`,
};
const parsedTaskInfo = shortSyntax(t, CONFIG, []);
const taskChanges = parsedTaskInfo?.taskChanges;
const plannedAt = taskChanges?.plannedAt as number;
expect(
checkIfCorrectDateMonthAndYear(
plannedAt,
inputDayOfTheMonth,
inputMonth,
tomorrow.getFullYear(),
16,
),
).toBeTrue();
expect(taskChanges?.timeEstimate).toEqual(minuteEstimate * 60 * 1000);
});
});
});
45 changes: 44 additions & 1 deletion src/app/features/tasks/short-syntax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,43 @@ const CH_DUE = '@';
const ALL_SPECIAL = `(\\${CH_PRO}|\\${CH_TAG}|\\${CH_DUE})`;

const customDateParser = casual.clone();
customDateParser.refiners.push({
refine: (context, results) => {
results.forEach((result) => {
const { refDate, text, start } = result;
const regex = / [5-9][0-9]$/;
const yearIndex = text.search(regex);
// The year pattern in Chrono's source code is (?:[1-9][0-9]{0,3}\\s{0,2}(?:BE|AD|BC|BCE|CE)|[1-2][0-9]{3}|[5-9][0-9]|2[0-5]).
// This means any two-digit numeric value from 50 to 99 will be considered a year.
// Link: https://github.com/wanasit/chrono/blob/54e7ff12f9185e735ee860c25922b2ab2367d40b/src/locales/en/constants.ts#L234C30-L234C108
// When someone creates a task like "Test @25/4 90m", Chrono will return the year as 1990, which is an undesirable behaviour in most cases.
if (yearIndex !== -1) {
result.text = text.slice(0, yearIndex);
const current = new Date();
let year = current.getFullYear();
// If the parsed month is smaller than the current month,
// it means the time is for next year. For example, parsed month is March
// and it is currently April
const impliedDate = start.get('day');
const impliedMonth = start.get('month');
// Due to the future-forward nature of the date parser, there are two scenarios that the implied year is next year:
// - Implied month is smaller than current month i.e. 20/3 vs 2/4
// - Same month but the implied date is before the current date i.e. 14/4 vs 20/4
if (
(impliedMonth && impliedMonth < refDate.getMonth() + 1) ||
(impliedMonth === refDate.getMonth() + 1 &&
impliedDate &&
impliedDate < refDate.getDate())
) {
// || (impliedMonth === refDate.getMonth() + 1 && impliedDay && impliedDay < refDate.getDay())
year += 1;
}
result.start.assign('year', year);
}
});
return results;
},
});

const SHORT_SYNTAX_PROJECT_REG_EX = new RegExp(`\\${CH_PRO}[^${ALL_SPECIAL}]+`, 'gi');
const SHORT_SYNTAX_TAGS_REG_EX = new RegExp(`\\${CH_TAG}[^${ALL_SPECIAL}|\\s]+`, 'gi');
Expand Down Expand Up @@ -285,7 +322,13 @@ const parseScheduledDate = (task: Partial<TaskCopy>, now: Date): DueChanges => {
if (!start.isCertain('hour')) {
hasPlannedTime = false;
}
const inputDate = parsedDateResult.text;
let inputDate = parsedDateResult.text;
// Hacky way to strip short syntax for time estimate that was
// accidentally included in the date parser
// For example: the task is "Task @14/4 90m" and we don't want "90m"
if (inputDate.match(/ [0-9]{1,}m/g)) {
inputDate += 'm';
}
return {
plannedAt,
// Strip out the short syntax for scheduled date and given date
Expand Down
16 changes: 16 additions & 0 deletions src/app/util/month-time-conversion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const MONTH_SHORT_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];

export const oneDayInMilliseconds = 24 * 60 * 60 * 1000;
Loading