Skip to content
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
3 changes: 1 addition & 2 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,8 @@ interface NLDSettings {
dutch: boolean;
spanish: boolean;
italian: boolean;
modalToggleTime: boolean;
modalToggleLink: boolean;
modalMomentFormat: string;
modalMomentFormat: string; // Moment.js format for the Date Picker, date-only (default: "YYYY-MM-DD")
// Smart suggestions
enableSmartSuggestions: boolean;
enableHistorySuggestions: boolean;
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- The autosuggest dropdown now shows a preview of the resolved date/time next to each suggestion (e.g. `Next Monday` — `2025-01-06`), so you can see what will actually be inserted before picking one.
- **Korean support** (partial): `오늘`/`내일`/`어제`, weekdays, `이번`/`다음`/`지난` prefixes, and suffix-style relative expressions (`3일 후`, `2주 전`). Vocabulary based on [CreamNuts' nldates-obsidian-korean](https://github.com/CreamNuts/nldates-obsidian-korean) (MIT), with thanks. Like the other partially-supported languages, chrono-node has no Korean parser to fall back on, so some combined phrasings (weekday + specific time, date ranges) aren't recognized yet — tracked in [#40](https://github.com/Amato21/nldates-revived/issues/40).

### Fixed
- Date Picker: picking a date via the calendar grid or a quick-select button (Today/Tomorrow/Next week/...) always inserted `12:00` as the time, regardless of what was actually selected. The modal was reformatting the selection down to a bare date string and re-parsing it through the NLP engine to build the preview/output, which discarded the actual time and let chrono-node's "no time specified" default (noon) leak through. Quick-select buttons had a related issue: they carried the real current wall-clock time instead of a clean date.

### Changed
- Date Picker no longer deals with time at all — it's a date picker. Previously, picking a date always carried *some* time value (the real current wall-clock time), with no way to control or clear it. Rather than adding a settings menu to make that time optional, the modal now always produces a plain date, even if the manual input field is used to type a phrase that includes a time (e.g. "today at 3pm"). The default format changed from `YYYY-MM-DD HH:mm` to `YYYY-MM-DD` accordingly. For dates that do need a specific time, use the "Insert the current date and time" command or type a full expression into the autosuggest instead.

## [0.9.71] - 2026-07-17

### Added
Expand Down
100 changes: 72 additions & 28 deletions main.js

Large diffs are not rendered by default.

129 changes: 89 additions & 40 deletions src/modals/date-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,13 @@ export default class DatePickerModal extends Modal {
constructor(app: App, plugin: NaturalLanguageDates) {
super(app);
this.plugin = plugin;
this.selectedDate = moment();
// Normalized to midnight: this is a date picker, not a time picker, and
// nothing in the UI lets the user set a time-of-day. Carrying the real
// current wall-clock time here (moment()) meant that opening the modal
// and immediately inserting produced whatever the clock happened to
// read, not a clean date -- and getDateStr() below formats *some*
// moment with a format string that includes "HH:mm" by default.
this.selectedDate = moment().startOf("day");
this.currentMonth = moment();
// Détecter le mode sombre
this.isDarkMode = activeDocument.body.classList.contains("theme-dark");
Expand All @@ -43,25 +49,58 @@ export default class DatePickerModal extends Modal {
let insertAsLink = this.plugin.settings.modalToggleLink;
let dateInput = "";

const getDateStr = () => {
let cleanDateInput = dateInput;
let shouldIncludeAlias = false;

if (dateInput.endsWith("|")) {
shouldIncludeAlias = true;
cleanDateInput = dateInput.slice(0, -1);
// Strips the trailing "|" that marks "keep as alias" (see shouldIncludeAlias
// below) -- shared so the input's onChange handler and getDateStr() agree
// on exactly what text gets parsed/cached.
const stripAliasSuffix = (text: string): string =>
text.endsWith("|") ? text.slice(0, -1) : text;

let cachedManualParse: { input: string; moment: Moment } | null = null;

// Parses manually-typed text via the NLP parser, caching the result.
// Called from both the input's onChange handler (to validate/update the
// calendar selection) and getDateStr() (to build the preview/output) --
// without the cache, typing a single character invoked
// plugin.parseDate() 2-3 times (onChange's own validation call, then
// updateSelectedDate -> updatePreview -> getDateStr, plus onChange's
// trailing updatePreview() call), which matters since NLP parsing isn't
// free.
const parseManualInput = (cleanText: string): Moment => {
if (cachedManualParse && cachedManualParse.input === cleanText) {
return cachedManualParse.moment;
}
const parsedMoment = this.plugin.parseDate(cleanText).moment;
cachedManualParse = { input: cleanText, moment: parsedMoment };
return parsedMoment;
};

const getDateStr = () => {
const shouldIncludeAlias = dateInput.endsWith("|");
const cleanDateInput = stripAliasSuffix(dateInput);

// Utiliser la date sélectionnée dans le calendrier si disponible
const dateToParse = cleanDateInput || this.selectedDate.format("YYYY-MM-DD");
const parsedDate = this.plugin.parseDate(dateToParse);

// Valider le format avant utilisation
const formatValidation = validateMomentFormat(momentFormat);
const formatToUse = formatValidation.valid ? momentFormat : DEFAULT_SETTINGS.modalMomentFormat;

let parsedDateString = parsedDate.moment.isValid()
? parsedDate.moment.format(formatToUse)

// Only round-trip through the NLP parser when the user actually typed
// something in the manual field (e.g. "next friday") -- this.selectedDate
// (calendar clicks, quick-select buttons) is already a resolved moment
// with nothing left to parse.
const parsedMoment = cleanDateInput
? parseManualInput(cleanDateInput)
: this.selectedDate;

// This is a date picker, not a time picker -- there's no time-of-day
// control anywhere in this modal's UI, so any time the NLP parser
// might have inferred from typed text (e.g. "today at 3pm") is
// deliberately discarded here, not just left at whatever default
// chrono-node happened to produce. Cloned first: parsedMoment may be
// the cached result from parseManualInput(), and moment's mutating
// .startOf() would otherwise corrupt that cache entry.
const momentToFormat = parsedMoment.isValid() ? parsedMoment.clone().startOf("day") : parsedMoment;

let parsedDateString = momentToFormat.isValid()
? momentToFormat.format(formatToUse)
: "";

if (insertAsLink) {
Expand Down Expand Up @@ -188,9 +227,14 @@ export default class DatePickerModal extends Modal {
textEl.onChange((value) => {
dateInput = value;
if (value) {
const parsed = this.plugin.parseDate(value);
if (parsed.moment.isValid()) {
updateSelectedDate(parsed.moment, false);
const parsedMoment = parseManualInput(stripAliasSuffix(value));
if (parsedMoment.isValid()) {
// updateSelectedDate() already calls updatePreview() itself;
// an unconditional call below on top of this would just
// re-run getDateStr() (and, before parseManualInput()'s
// caching, re-invoke the NLP parser) for no reason.
updateSelectedDate(parsedMoment, false);
return;
}
}
updatePreview();
Expand All @@ -206,12 +250,12 @@ export default class DatePickerModal extends Modal {
.setName("Date format")
.setDesc("Moment format to be used")
.addMomentFormat((momentEl) => {
momentEl.setPlaceholder("YYYY-MM-DD HH:mm");
momentEl.setPlaceholder("YYYY-MM-DD");
momentEl.setValue(momentFormat);
momentEl.onChange((value) => {
const validated = validateMomentFormat(value.trim() || "YYYY-MM-DD HH:mm");
const validated = validateMomentFormat(value.trim() || "YYYY-MM-DD");
if (validated.valid) {
momentFormat = value.trim() || "YYYY-MM-DD HH:mm";
momentFormat = value.trim() || "YYYY-MM-DD";
this.plugin.settings.modalMomentFormat = momentFormat;
void this.plugin.saveSettings();
updatePreview();
Expand Down Expand Up @@ -283,30 +327,35 @@ export default class DatePickerModal extends Modal {
return translation.split("|")[0].trim();
};

// .startOf("day") on every option: without it these carried the real
// current wall-clock time (e.g. clicking "Tomorrow" at 14:32 selected
// tomorrow at 14:32, not a clean date), inconsistent with calendar-grid
// clicks (already midnight-based) and liable to the same "unexpected
// time baked into the output" confusion as the getDateStr() bug above.
const quickOptions = [
{
label: getFirstVariant("today"),
moment: moment()
{
label: getFirstVariant("today"),
moment: moment().startOf("day")
},
{
label: getFirstVariant("tomorrow"),
moment: moment().add(1, "day")
{
label: getFirstVariant("tomorrow"),
moment: moment().add(1, "day").startOf("day")
},
{
label: getFirstVariant("yesterday"),
moment: moment().subtract(1, "day")
{
label: getFirstVariant("yesterday"),
moment: moment().subtract(1, "day").startOf("day")
},
{
label: `${getFirstVariant("next")} ${getFirstVariant("week")}`,
moment: moment().add(1, "week")
{
label: `${getFirstVariant("next")} ${getFirstVariant("week")}`,
moment: moment().add(1, "week").startOf("day")
},
{
label: `${getFirstVariant("next")} ${getFirstVariant("month")}`,
moment: moment().add(1, "month")
{
label: `${getFirstVariant("next")} ${getFirstVariant("month")}`,
moment: moment().add(1, "month").startOf("day")
},
{
label: `${getFirstVariant("next")} ${getFirstVariant("year")}`,
moment: moment().add(1, "year")
{
label: `${getFirstVariant("next")} ${getFirstVariant("year")}`,
moment: moment().add(1, "year").startOf("day")
},
];

Expand Down Expand Up @@ -451,7 +500,7 @@ export default class DatePickerModal extends Modal {
break;
case "Home":
e.preventDefault();
updateSelectedDate(moment());
updateSelectedDate(moment().startOf("day"));
this.currentMonth = moment();
this.renderCalendar();
break;
Expand Down
10 changes: 7 additions & 3 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,13 @@ export interface NLDSettings {
chinese: boolean;
korean: boolean;

modalToggleTime: boolean;
modalToggleLink: boolean;
// Date-only: the Date Picker deliberately never surfaces a time -- there's
// no time-of-day control anywhere in that modal's UI, so a format
// including time tokens would only ever display a meaningless constant
// (the picked date normalized to midnight). Use the "Insert the current
// date and time" command, or type a full expression into the autosuggest,
// for anything that actually needs a time.
modalMomentFormat: string;

// Smart suggestions
Expand Down Expand Up @@ -83,9 +88,8 @@ export const DEFAULT_SETTINGS: NLDSettings = {
chinese: false,
korean: false,

modalToggleTime: false,
modalToggleLink: false,
modalMomentFormat: "YYYY-MM-DD HH:mm",
modalMomentFormat: "YYYY-MM-DD",

// Smart suggestions
enableSmartSuggestions: true,
Expand Down
143 changes: 143 additions & 0 deletions tests/date-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,5 +360,148 @@ describe('DatePickerModal Integration Tests', () => {
expect(inputElBefore.value).toBe(valueBefore);
});
});

describe('Calendar/quick-button selections must not insert a spurious noon time (regression)', () => {
// Reported: picking a date via the calendar or a quick-select button
// always inserted "12:00" as the time, regardless of what was actually
// selected. Root cause: getDateStr() used to re-derive the date from
// this.selectedDate by formatting it down to a bare "YYYY-MM-DD" string
// and feeding that back into plugin.parseDate() -- discarding whatever
// time this.selectedDate actually had. chrono-node defaults a date given
// with no time component to noon, so that round-trip always produced
// 12:00 once formatted with modalMomentFormat's format at the time,
// which included "HH:mm" (the format is now date-only, see below).
//
// This mock reproduces that exact real-world chrono behavior (unlike
// this file's default beforeEach mock, which uses plain moment(text) --
// moment("2026-07-20") parses to midnight, not noon, so it would never
// have caught this bug).
function mockParseDateAlwaysNoon() {
return vi.fn((text: string) => {
const withNoon = moment(text || 'today').startOf('day').add(12, 'hours');
return {
formattedString: withNoon.format('YYYY-MM-DD'),
date: withNoon.toDate(),
moment: withNoon,
};
});
}

function fakeEl(): any {
const el: any = {
empty: () => {},
addClass: () => {},
removeClass: () => {},
addEventListener: () => {},
setText: () => {},
selected: false,
value: '',
createDiv: (_cls?: unknown, cb?: (e: unknown) => void) => {
const child = fakeEl();
if (typeof cb === 'function') cb(child);
return child;
},
createEl: (_tag?: unknown, _opts?: unknown, cb?: (e: unknown) => void) => {
const child = fakeEl();
if (typeof cb === 'function') cb(child);
return child;
},
createSpan: (_opts?: unknown, cb?: (e: unknown) => void) => {
const child = fakeEl();
if (typeof cb === 'function') cb(child);
return child;
},
};
return el;
}

function openModalWithPreviewSpy() {
Setting.resetInstances();
(modal as any).contentEl = fakeEl();
(window as any).setTimeout = (fn: () => void) => fn();
(globalThis as any).MutationObserver = class {
observe() {}
disconnect() {}
};
modal.onOpen();

const dateSetting = Setting.instances.find((s: any) => s.nameText === 'Date');
const previewSpy = vi.fn();
dateSetting.descEl.setText = previewSpy;
return previewSpy;
}

it('does not call plugin.parseDate (and does not show noon) when a calendar day is clicked', () => {
plugin.parseDate = mockParseDateAlwaysNoon();
const previewSpy = openModalWithPreviewSpy();

const clickedDay = moment('2026-07-20').startOf('day'); // calendar cells are midnight-based
(modal as any).updateSelectedDateFn(clickedDay);

expect(plugin.parseDate).not.toHaveBeenCalled();
const lastPreview = previewSpy.mock.calls.at(-1)?.[0];
expect(lastPreview).not.toContain('12:00');
expect(lastPreview).toContain('2026-07-20');
});

it('does not show noon for a quick-select button pick either', () => {
plugin.parseDate = mockParseDateAlwaysNoon();
const previewSpy = openModalWithPreviewSpy();

// Simulates the "Tomorrow" quick button: startOf("day") applied, no
// real wall-clock time leaking through.
const tomorrow = moment().add(1, 'day').startOf('day');
(modal as any).updateSelectedDateFn(tomorrow);

expect(plugin.parseDate).not.toHaveBeenCalled();
const lastPreview = previewSpy.mock.calls.at(-1)?.[0];
expect(lastPreview).not.toContain('12:00');
});

it('still parses typed free-form text through the NLP parser, but discards any time it carries (this is a date picker, not a time picker)', () => {
// A custom format that *would* reveal a leaked time if one weren't
// discarded -- the default "YYYY-MM-DD" can't tell HH:mm leaking
// through apart from HH:mm being correctly stripped, since neither
// token is in the format string either way.
plugin.settings.modalMomentFormat = 'YYYY-MM-DD HH:mm';
plugin.parseDate = vi.fn(() => ({
formattedString: '2026-07-24 15:00',
date: moment('2026-07-24 15:00').toDate(),
moment: moment('2026-07-24 15:00'),
})) as any;
const previewSpy = openModalWithPreviewSpy();

const dateSetting = Setting.instances.find((s: any) => s.nameText === 'Date');
const textComponent = dateSetting.components[0];
textComponent.onChangeHandler('friday at 3pm');

expect(plugin.parseDate).toHaveBeenCalledWith('friday at 3pm');
const lastPreview = previewSpy.mock.calls.at(-1)?.[0];
expect(lastPreview).not.toContain('15:00');
expect(lastPreview).toContain('2026-07-24');
expect(lastPreview).toContain('00:00');
});

it('defaults selectedDate to midnight on construction, not the real current time', () => {
const fresh = new (modal.constructor as any)(mockApp, plugin);
expect(fresh.selectedDate.hour()).toBe(0);
expect(fresh.selectedDate.minute()).toBe(0);
});

it('parses typed input through the NLP parser only once per keystroke, not 2-3 times (regression)', () => {
plugin.parseDate = vi.fn(() => ({
formattedString: '2026-07-24',
date: moment('2026-07-24').toDate(),
moment: moment('2026-07-24'),
})) as any;
openModalWithPreviewSpy();

const dateSetting = Setting.instances.find((s: any) => s.nameText === 'Date');
const textComponent = dateSetting.components[0];
textComponent.onChangeHandler('friday');

expect(plugin.parseDate).toHaveBeenCalledTimes(1);
});
});
});

Loading