mirror of
https://github.com/outline/outline.git
synced 2026-08-03 13:27:25 +03:00
Add time support to date mentions (#13222)
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
toISODateTime,
|
||||
} from "@shared/utils/date";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
|
||||
@@ -68,7 +69,7 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
// Surface a date suggestion when the search query parses as a natural
|
||||
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
|
||||
// language date (e.g. "tomorrow", "next friday", "jan 2", "1pm"). Parsing is
|
||||
// asynchronous as chrono-node is loaded lazily, so the result is held in
|
||||
// state and applied once resolved.
|
||||
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
|
||||
@@ -80,9 +81,15 @@ function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
}
|
||||
let cancelled = false;
|
||||
void parseNaturalLanguageDate(search)
|
||||
.then((date) => {
|
||||
.then((parsed) => {
|
||||
if (!cancelled) {
|
||||
setParsedISODate(date ? toISODate(date) : undefined);
|
||||
setParsedISODate(
|
||||
parsed
|
||||
? parsed.hasTime
|
||||
? toISODateTime(parsed.date)
|
||||
: toISODate(parsed.date)
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { set } from "date-fns";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RemoveScroll } from "react-remove-scroll";
|
||||
import styled from "styled-components";
|
||||
import { Calendar } from "../../components/Calendar";
|
||||
import { depths, s } from "../../styles";
|
||||
import { dateLocale, toISODate } from "../../utils/date";
|
||||
import { dateLocale, toISODate, toISODateTime } from "../../utils/date";
|
||||
|
||||
type Props = {
|
||||
/** The currently selected date, if any. */
|
||||
selectedDate?: Date;
|
||||
/** Whether the selected date is time-specific. */
|
||||
includeTime?: boolean;
|
||||
/** The user's language, used to localise the calendar. */
|
||||
language?: Parameters<typeof dateLocale>[0];
|
||||
/** Called with the new date-only ISO string when a day is picked. */
|
||||
/** Called with the new ISO string when a day is picked. */
|
||||
onChange: (modelId: string) => void;
|
||||
/** The trigger element the calendar popover is anchored to. */
|
||||
children: React.ReactNode;
|
||||
@@ -29,6 +32,7 @@ type Props = {
|
||||
*/
|
||||
export default function DateMentionPicker({
|
||||
selectedDate,
|
||||
includeTime,
|
||||
language,
|
||||
onChange,
|
||||
children,
|
||||
@@ -39,9 +43,23 @@ export default function DateMentionPicker({
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
|
||||
// The calendar only changes the day, so an existing time is carried over.
|
||||
if (includeTime && selectedDate) {
|
||||
onChange(
|
||||
toISODateTime(
|
||||
set(date, {
|
||||
hours: selectedDate.getHours(),
|
||||
minutes: selectedDate.getMinutes(),
|
||||
})
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onChange(toISODate(date));
|
||||
},
|
||||
[onChange]
|
||||
[onChange, includeTime, selectedDate]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,7 +10,11 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import { dateToRelativeReadable, parseISODate } from "../../utils/date";
|
||||
import {
|
||||
dateToRelativeReadable,
|
||||
hasTimeComponent,
|
||||
parseISODate,
|
||||
} from "../../utils/date";
|
||||
import { Backticks } from "../../components/Backticks";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
@@ -554,6 +558,7 @@ export const MentionDate = observer(function MentionDate_(props: DateProps) {
|
||||
<React.Suspense fallback={content}>
|
||||
<DateMentionPicker
|
||||
selectedDate={selectedDate}
|
||||
includeTime={hasTimeComponent(iso)}
|
||||
language={language}
|
||||
onChange={onChangeDate}
|
||||
>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import env from "../../env";
|
||||
import type { UnfurlResponse } from "../../types";
|
||||
import { MentionType, UnfurlResourceType } from "../../types";
|
||||
import { dateToReadable } from "../../utils/date";
|
||||
import {
|
||||
MentionCollection,
|
||||
MentionDocument,
|
||||
@@ -34,16 +35,35 @@ import mentionRule from "../rules/mention";
|
||||
import type { ComponentProps } from "../types";
|
||||
import Node from "./Node";
|
||||
|
||||
/**
|
||||
* Formats a date mention's stored value (a date-only or time-specific ISO
|
||||
* string) into a human-readable label for display and serialization.
|
||||
*
|
||||
* @param node the date mention node.
|
||||
* @returns the readable label, e.g. "February 3rd at 1:00 PM".
|
||||
*/
|
||||
function dateMentionLabel(node: ProsemirrorNode): string {
|
||||
const modelId = node.attrs.modelId;
|
||||
return typeof modelId === "string"
|
||||
? dateToReadable(modelId)
|
||||
: node.attrs.label;
|
||||
}
|
||||
|
||||
export default class Mention extends Node {
|
||||
get name() {
|
||||
return "mention";
|
||||
}
|
||||
|
||||
get schema(): NodeSpec {
|
||||
const toPlainText = (node: ProsemirrorNode) =>
|
||||
node.attrs.type === MentionType.User
|
||||
? `@${node.attrs.label}`
|
||||
: node.attrs.label;
|
||||
const toPlainText = (node: ProsemirrorNode) => {
|
||||
if (node.attrs.type === MentionType.User) {
|
||||
return `@${node.attrs.label}`;
|
||||
}
|
||||
if (node.attrs.type === MentionType.Date) {
|
||||
return dateMentionLabel(node);
|
||||
}
|
||||
return node.attrs.label;
|
||||
};
|
||||
|
||||
return {
|
||||
attrs: {
|
||||
@@ -341,7 +361,10 @@ export default class Mention extends Node {
|
||||
toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) {
|
||||
const mType = node.attrs.type;
|
||||
const mId = node.attrs.modelId;
|
||||
const label = node.attrs.label;
|
||||
// Date mentions store a machine-readable value, so the label is derived to
|
||||
// keep the serialized output legible outside of the editor.
|
||||
const label =
|
||||
mType === MentionType.Date ? dateMentionLabel(node) : node.attrs.label;
|
||||
const id = node.attrs.id;
|
||||
|
||||
// Use regular links for document and collection mentions
|
||||
|
||||
@@ -111,6 +111,19 @@ describe("mention rule", () => {
|
||||
expect(mentions[0].modelId).toBe("2024-02-03");
|
||||
expect(mentions[0].label).toBe("February 3rd, 2024");
|
||||
});
|
||||
|
||||
it("should parse a date mention with a time-specific modelId", () => {
|
||||
const result = md.parse(
|
||||
"@[February 3rd, 2024 at 1:00 PM](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03T13:00)",
|
||||
{}
|
||||
);
|
||||
const mentions = findMentionTokens(result);
|
||||
|
||||
expect(mentions).toHaveLength(1);
|
||||
expect(mentions[0].type).toBe("date");
|
||||
expect(mentions[0].modelId).toBe("2024-02-03T13:00");
|
||||
expect(mentions[0].label).toBe("February 3rd, 2024 at 1:00 PM");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed content", () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
hasTimeComponent,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
toISODateTime,
|
||||
} from "./date";
|
||||
|
||||
describe("toISODate / parseISODate", () => {
|
||||
@@ -17,8 +19,9 @@ describe("toISODate / parseISODate", () => {
|
||||
expect(parseISODate("not-a-date")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects strings carrying a time component", () => {
|
||||
it("rejects strings carrying seconds or a timezone", () => {
|
||||
expect(parseISODate("2024-02-03T10:00:00Z")).toBeNull();
|
||||
expect(parseISODate("2024-02-03T10:00:00")).toBeNull();
|
||||
});
|
||||
|
||||
it("parses a date-only string to local midnight", () => {
|
||||
@@ -28,6 +31,21 @@ describe("toISODate / parseISODate", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toISODateTime / hasTimeComponent", () => {
|
||||
it("round-trips a date and time through its ISO representation", () => {
|
||||
const date = new Date(2024, 1, 3, 13, 5); // Feb 3, 2024 at 1:05pm
|
||||
const iso = toISODateTime(date);
|
||||
expect(iso).toBe("2024-02-03T13:05");
|
||||
expect(parseISODate(iso)).toEqual(date);
|
||||
});
|
||||
|
||||
it("detects whether a value is time-specific", () => {
|
||||
expect(hasTimeComponent("2024-02-03T13:05")).toBe(true);
|
||||
expect(hasTimeComponent("2024-02-03")).toBe(false);
|
||||
expect(hasTimeComponent("nonsense")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateToReadable", () => {
|
||||
it("includes the year outside the current year", () => {
|
||||
expect(dateToReadable("2020-02-03")).toBe("February 3rd, 2020");
|
||||
@@ -44,6 +62,24 @@ describe("dateToReadable", () => {
|
||||
it("returns the original string when invalid", () => {
|
||||
expect(dateToReadable("nonsense")).toBe("nonsense");
|
||||
});
|
||||
|
||||
it("appends the time when the value is time-specific", () => {
|
||||
expect(dateToReadable("2020-02-03T13:00")).toBe(
|
||||
"February 3rd, 2020 at 1:00 PM"
|
||||
);
|
||||
});
|
||||
|
||||
it("separates the time using the locale's own connector", () => {
|
||||
expect(dateToReadable("2020-02-03T13:00", "de_DE")).toContain(" um ");
|
||||
expect(dateToReadable("2020-02-03T13:00", "ja_JP")).not.toContain(" at ");
|
||||
});
|
||||
|
||||
it("does not leak the date's own suffix into the separator", () => {
|
||||
// Ukrainian places "р." (an abbreviation of "року") after the year, which
|
||||
// must not be mistaken for part of the separator.
|
||||
expect(dateToReadable("2020-02-03T13:00", "uk_UA")).toContain(" о ");
|
||||
expect(dateToReadable("2020-02-03T13:00", "uk_UA")).not.toContain("р.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateToRelativeReadable", () => {
|
||||
@@ -77,4 +113,15 @@ describe("dateToRelativeReadable", () => {
|
||||
it("includes the year for a date in a different year", () => {
|
||||
expect(dateToRelativeReadable("2020-02-03", t)).toBe("February 3rd, 2020");
|
||||
});
|
||||
|
||||
it("appends the time when the value is time-specific", () => {
|
||||
const today = new Date();
|
||||
today.setHours(13, 0);
|
||||
expect(dateToRelativeReadable(toISODateTime(today), t)).toBe(
|
||||
"Today at 1:00 PM"
|
||||
);
|
||||
expect(dateToRelativeReadable("2020-02-03T13:00", t)).toBe(
|
||||
"February 3rd, 2020 at 1:00 PM"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+128
-26
@@ -321,29 +321,122 @@ export function toISODate(date: Date): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight.
|
||||
* Strings carrying a time component are rejected so the date-only contract
|
||||
* (and the day-granular comparisons that depend on it) cannot be violated.
|
||||
* Formats a Date into a date and time ISO string (yyyy-MM-dd'T'HH:mm) in the
|
||||
* local timezone. Used as the stored value for time-specific date mentions.
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @returns the parsed Date at local midnight, or null when the string is not a
|
||||
* valid date-only value.
|
||||
* @param date The date to format.
|
||||
* @returns the date and time ISO string.
|
||||
*/
|
||||
export function toISODateTime(date: Date): string {
|
||||
return format(date, "yyyy-MM-dd'T'HH:mm");
|
||||
}
|
||||
|
||||
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const isoDateTimeRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
|
||||
|
||||
/**
|
||||
* Whether a date mention's stored ISO value carries a time component.
|
||||
*
|
||||
* @param iso The stored ISO string.
|
||||
* @returns true when the value is time-specific.
|
||||
*/
|
||||
export function hasTimeComponent(iso: string): boolean {
|
||||
return isoDateTimeRegex.test(iso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date mention's stored ISO string into a Date in the local timezone.
|
||||
* Accepts both the date-only (yyyy-MM-dd) and time-specific
|
||||
* (yyyy-MM-dd'T'HH:mm) forms, the former resolving to local midnight. Any other
|
||||
* shape – including values carrying seconds or a timezone offset – is rejected
|
||||
* so the local, minute-granular contract cannot be violated.
|
||||
*
|
||||
* @param iso The stored ISO string.
|
||||
* @returns the parsed Date, or null when the string is not a valid value.
|
||||
*/
|
||||
export function parseISODate(iso: string): Date | null {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) {
|
||||
if (!isoDateRegex.test(iso) && !isoDateTimeRegex.test(iso)) {
|
||||
return null;
|
||||
}
|
||||
const date = parseISO(iso);
|
||||
return isValid(date) ? date : null;
|
||||
}
|
||||
|
||||
const separators = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Returns the separator a locale places between the date and time halves of a
|
||||
* formatted datetime, e.g. " at " in English or " um " in German. It is read
|
||||
* from the platform's own formatter so the label needs no translation.
|
||||
*/
|
||||
function dateTimeSeparator(language?: keyof typeof locales | null): string {
|
||||
// Without a language the date and time halves fall back to date-fns' English
|
||||
// locale, so the separator must be English too rather than the platform's.
|
||||
const tag = language ? language.replace("_", "-") : "en-US";
|
||||
|
||||
let separator = separators.get(tag);
|
||||
if (separator !== undefined) {
|
||||
return separator;
|
||||
}
|
||||
|
||||
separator = " ";
|
||||
|
||||
if (typeof Intl !== "undefined") {
|
||||
const sample = new Date(2000, 11, 25, 13, 0);
|
||||
const parts = new Intl.DateTimeFormat(tag, {
|
||||
dateStyle: "long",
|
||||
timeStyle: "short",
|
||||
}).formatToParts(sample);
|
||||
|
||||
const timeIndex = parts.findIndex((part) =>
|
||||
["hour", "minute", "dayPeriod"].includes(part.type)
|
||||
);
|
||||
const preceding = parts[timeIndex - 1];
|
||||
if (preceding?.type === "literal") {
|
||||
// The literal can begin with a suffix belonging to the date itself (the
|
||||
// "日" in Japanese, the " р." in Ukrainian). It shows up as the trailing
|
||||
// literal of the date-only format, so it can be stripped off.
|
||||
const dateParts = new Intl.DateTimeFormat(tag, {
|
||||
dateStyle: "long",
|
||||
}).formatToParts(sample);
|
||||
const dateSuffix = dateParts[dateParts.length - 1];
|
||||
|
||||
let value = preceding.value;
|
||||
if (
|
||||
dateSuffix?.type === "literal" &&
|
||||
value.startsWith(dateSuffix.value)
|
||||
) {
|
||||
value = value.slice(dateSuffix.value.length);
|
||||
}
|
||||
const whitespace = value.search(/\s/);
|
||||
separator = whitespace === -1 ? " " : value.slice(whitespace);
|
||||
}
|
||||
}
|
||||
|
||||
separators.set(tag, separator);
|
||||
return separator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the readable date and time halves of a label with the separator
|
||||
* appropriate for the locale.
|
||||
*/
|
||||
function joinDateAndTime(
|
||||
dateString: string,
|
||||
timeString: string,
|
||||
language?: keyof typeof locales | null
|
||||
): string {
|
||||
return `${dateString}${dateTimeSeparator(language)}${timeString}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date mention's stored ISO value into an absolute, localized,
|
||||
* human-readable label. The year is omitted within the current year (e.g.
|
||||
* "January 2nd") and included otherwise (e.g. "February 3rd, 2024"). Suitable
|
||||
* for plaintext and markdown serialization.
|
||||
* "January 2nd") and included otherwise (e.g. "February 3rd, 2024"). The time
|
||||
* is appended when the value is time-specific (e.g. "January 2nd at 1:00 PM").
|
||||
* Suitable for plaintext and markdown serialization.
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @param iso The stored ISO string.
|
||||
* @param language The user's language preference.
|
||||
* @returns the absolute human-readable date, or the original string when invalid.
|
||||
*/
|
||||
@@ -356,19 +449,24 @@ export function dateToReadable(
|
||||
return iso;
|
||||
}
|
||||
const locale = dateLocale(language);
|
||||
if (isSameYear(date, new Date())) {
|
||||
return format(date, "MMMM do", { locale });
|
||||
const dateString = isSameYear(date, new Date())
|
||||
? format(date, "MMMM do", { locale })
|
||||
: format(date, "MMMM do, yyyy", { locale });
|
||||
|
||||
if (!hasTimeComponent(iso)) {
|
||||
return dateString;
|
||||
}
|
||||
return format(date, "MMMM do, yyyy", { locale });
|
||||
return joinDateAndTime(dateString, format(date, "p", { locale }), language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date mention's stored ISO value into a relative, localized,
|
||||
* human-readable label with increasing granularity. Returns "Today",
|
||||
* "Tomorrow" or "Yesterday" where applicable, "January 2nd" within the
|
||||
* current year, and "February 3rd, 2024" otherwise.
|
||||
* current year, and "February 3rd, 2024" otherwise. The time is appended when
|
||||
* the value is time-specific (e.g. "Tomorrow at 1:00 PM").
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @param iso The stored ISO string.
|
||||
* @param t The translation function.
|
||||
* @param language The user's language preference.
|
||||
* @returns the relative human-readable date, or the original string when invalid.
|
||||
@@ -383,19 +481,23 @@ export function dateToRelativeReadable(
|
||||
return iso;
|
||||
}
|
||||
|
||||
const locale = dateLocale(language);
|
||||
let dateString;
|
||||
|
||||
if (isToday(date)) {
|
||||
return t("Today");
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return t("Tomorrow");
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return t("Yesterday");
|
||||
dateString = t("Today");
|
||||
} else if (isTomorrow(date)) {
|
||||
dateString = t("Tomorrow");
|
||||
} else if (isYesterday(date)) {
|
||||
dateString = t("Yesterday");
|
||||
} else if (isSameYear(date, new Date())) {
|
||||
dateString = format(date, "MMMM do", { locale });
|
||||
} else {
|
||||
dateString = format(date, "MMMM do, yyyy", { locale });
|
||||
}
|
||||
|
||||
const locale = dateLocale(language);
|
||||
if (isSameYear(date, new Date())) {
|
||||
return format(date, "MMMM do", { locale });
|
||||
if (!hasTimeComponent(iso)) {
|
||||
return dateString;
|
||||
}
|
||||
return format(date, "MMMM do, yyyy", { locale });
|
||||
return joinDateAndTime(dateString, format(date, "p", { locale }), language);
|
||||
}
|
||||
|
||||
@@ -48,6 +48,35 @@ describe("parseMentionUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a date modelId", () => {
|
||||
expect(
|
||||
parseMentionUrl(
|
||||
"mention://9a17c1c8-d178-4350-9001-203a73070fcb/date/2024-02-03"
|
||||
)
|
||||
).toEqual({
|
||||
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
mentionType: "date",
|
||||
modelId: "2024-02-03",
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse a datetime modelId", () => {
|
||||
expect(
|
||||
parseMentionUrl(
|
||||
"mention://9a17c1c8-d178-4350-9001-203a73070fcb/date/2024-02-03T13:00"
|
||||
)
|
||||
).toEqual({
|
||||
id: "9a17c1c8-d178-4350-9001-203a73070fcb",
|
||||
mentionType: "date",
|
||||
modelId: "2024-02-03T13:00",
|
||||
});
|
||||
|
||||
expect(parseMentionUrl("mention://date/2024-02-03T13:00")).toEqual({
|
||||
mentionType: "date",
|
||||
modelId: "2024-02-03T13:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty object for invalid URL", () => {
|
||||
expect(parseMentionUrl("https://example.com")).toEqual({});
|
||||
});
|
||||
|
||||
@@ -10,15 +10,18 @@
|
||||
const parseMentionUrl = (
|
||||
url: string
|
||||
): { id?: string; mentionType?: string; modelId?: string } => {
|
||||
// The modelId is a UUID, a date (2024-02-03) or a datetime (2024-02-03T13:00).
|
||||
const match3 = url.match(
|
||||
/^mention:\/\/([a-z0-9-]+)\/([a-z_]+)\/([a-z0-9-]+)$/
|
||||
/^mention:\/\/([a-z0-9-]+)\/([a-z_]+)\/([a-z0-9-]+(?:T\d{2}:\d{2})?)$/
|
||||
);
|
||||
if (match3) {
|
||||
const [id, mentionType, modelId] = match3.slice(1);
|
||||
return { id, mentionType, modelId };
|
||||
}
|
||||
|
||||
const match2 = url.match(/^mention:\/\/([a-z_]+)\/([a-z0-9-]+)$/);
|
||||
const match2 = url.match(
|
||||
/^mention:\/\/([a-z_]+)\/([a-z0-9-]+(?:T\d{2}:\d{2})?)$/
|
||||
);
|
||||
if (match2) {
|
||||
const [mentionType, modelId] = match2.slice(1);
|
||||
return { mentionType, modelId };
|
||||
|
||||
@@ -14,32 +14,85 @@ describe("parseNaturalLanguageDate", () => {
|
||||
|
||||
it("parses 'today'", async () => {
|
||||
const result = await parseNaturalLanguageDate("today", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 1));
|
||||
expect(result).toEqual({ date: new Date(2024, 0, 1), hasTime: false });
|
||||
});
|
||||
|
||||
it("parses 'tomorrow'", async () => {
|
||||
const result = await parseNaturalLanguageDate("tomorrow", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 2));
|
||||
expect(result).toEqual({ date: new Date(2024, 0, 2), hasTime: false });
|
||||
});
|
||||
|
||||
it("parses 'yesterday'", async () => {
|
||||
const result = await parseNaturalLanguageDate("yesterday", reference);
|
||||
expect(result).toEqual(new Date(2023, 11, 31));
|
||||
expect(result).toEqual({ date: new Date(2023, 11, 31), hasTime: false });
|
||||
});
|
||||
|
||||
it("parses 'in 3 days'", async () => {
|
||||
const result = await parseNaturalLanguageDate("in 3 days", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 4));
|
||||
expect(result).toEqual({ date: new Date(2024, 0, 4), hasTime: false });
|
||||
});
|
||||
|
||||
it("parses an explicit month and day", async () => {
|
||||
const result = await parseNaturalLanguageDate("February 3", reference);
|
||||
expect(result).toEqual(new Date(2024, 1, 3));
|
||||
expect(result).toEqual({ date: new Date(2024, 1, 3), hasTime: false });
|
||||
});
|
||||
|
||||
it("normalizes the time component to local midnight", async () => {
|
||||
it("normalizes the time component to local midnight when none is given", async () => {
|
||||
const result = await parseNaturalLanguageDate("next friday", reference);
|
||||
expect(result?.hasTime).toBe(false);
|
||||
expect(result?.date.getHours()).toBe(0);
|
||||
expect(result?.date.getMinutes()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the time component when one is given", async () => {
|
||||
const result = await parseNaturalLanguageDate("tomorrow at 5pm", reference);
|
||||
expect(result?.getHours()).toBe(0);
|
||||
expect(result?.getMinutes()).toBe(0);
|
||||
expect(result).toEqual({
|
||||
date: new Date(2024, 0, 2, 17, 0),
|
||||
hasTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a time on its own", async () => {
|
||||
const result = await parseNaturalLanguageDate("1pm", reference);
|
||||
expect(result).toEqual({
|
||||
date: new Date(2024, 0, 1, 13, 0),
|
||||
hasTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a morning time", async () => {
|
||||
const result = await parseNaturalLanguageDate("9am", reference);
|
||||
expect(result).toEqual({
|
||||
date: new Date(2024, 0, 1, 9, 0),
|
||||
hasTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a morning time with minutes", async () => {
|
||||
const result = await parseNaturalLanguageDate("8:30am tomorrow", reference);
|
||||
expect(result).toEqual({
|
||||
date: new Date(2024, 0, 2, 8, 30),
|
||||
hasTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rolls a time that has already passed forward to the next day", async () => {
|
||||
const afternoon = new Date(2024, 0, 1, 15, 0); // Mon Jan 1, 2024 at 3pm
|
||||
const result = await parseNaturalLanguageDate("9am", afternoon);
|
||||
expect(result).toEqual({
|
||||
date: new Date(2024, 0, 2, 9, 0),
|
||||
hasTime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("parses a time on a date in the past", async () => {
|
||||
const result = await parseNaturalLanguageDate(
|
||||
"yesterday at 7am",
|
||||
reference
|
||||
);
|
||||
expect(result).toEqual({
|
||||
date: new Date(2023, 11, 31, 7, 0),
|
||||
hasTime: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,25 +23,31 @@ function loadChrono(): Promise<typeof Chrono> {
|
||||
return chronoPromise;
|
||||
}
|
||||
|
||||
export interface ParsedNaturalLanguageDate {
|
||||
/** The matched date, at local midnight unless a time was given. */
|
||||
date: Date;
|
||||
/** Whether the input named a specific time of day. */
|
||||
hasTime: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a natural language string such as "tomorrow", "next friday",
|
||||
* "jan 2" or "in 3 days" into a calendar date.
|
||||
* "jan 2", "in 3 days" or "1pm" into a calendar date.
|
||||
*
|
||||
* The time component is intentionally discarded as date mentions are
|
||||
* day-granular; only the year, month and day of the matched date are
|
||||
* returned. chrono-node is loaded asynchronously the first time this is
|
||||
* called.
|
||||
* The time component is only kept when the input actually named one,
|
||||
* otherwise the matched date is normalized to local midnight. chrono-node is
|
||||
* loaded asynchronously the first time this is called.
|
||||
*
|
||||
* @param input the natural language string to parse.
|
||||
* @param referenceDate the date relative to which terms like "tomorrow"
|
||||
* are resolved, defaults to now.
|
||||
* @returns a promise resolving to the matched date with the time set to
|
||||
* local midnight, or null when no date could be confidently parsed.
|
||||
* @returns a promise resolving to the matched date and whether it is
|
||||
* time-specific, or null when no date could be confidently parsed.
|
||||
*/
|
||||
export async function parseNaturalLanguageDate(
|
||||
input: string,
|
||||
referenceDate: Date = new Date()
|
||||
): Promise<Date | null> {
|
||||
): Promise<ParsedNaturalLanguageDate | null> {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
@@ -61,5 +67,18 @@ export async function parseNaturalLanguageDate(
|
||||
}
|
||||
|
||||
const date = result.start.date();
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const hasTime = result.start.isCertain("hour");
|
||||
|
||||
return {
|
||||
date: hasTime
|
||||
? new Date(
|
||||
date.getFullYear(),
|
||||
date.getMonth(),
|
||||
date.getDate(),
|
||||
date.getHours(),
|
||||
date.getMinutes()
|
||||
)
|
||||
: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
|
||||
hasTime,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user