mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Add date mentions to the editor
Introduce a new "date" mention type alongside the existing user, document and collection mentions. Typing @ with a natural language date (e.g. "tomorrow", "next friday", "jan 2") surfaces a date suggestion, parsed via chrono-node. Dates are stored as date-only ISO strings and displayed with increasing granularity (Today / Tomorrow / January 2nd / February 3rd, 2024), recomputed dynamically so relative labels stay fresh. Clicking a date mention opens a Radix popover calendar to change it.
This commit is contained in:
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DateSection = ({ t }: ActionContext) => t("Date");
|
||||
|
||||
DateSection.priority = 1;
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SearchResultsSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
CalendarIcon,
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
NewDocumentIcon,
|
||||
@@ -14,11 +15,18 @@ import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
toISODate,
|
||||
} from "@shared/utils/date";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
|
||||
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DateSection,
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
@@ -26,6 +34,7 @@ import {
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
@@ -54,8 +63,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const userLocale = useUserLocale();
|
||||
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").
|
||||
const parsedDate = search ? parseNaturalLanguageDate(search) : null;
|
||||
const parsedISODate = parsedDate ? toISODate(parsedDate) : undefined;
|
||||
const dateItems: MentionItem[] =
|
||||
actorId && parsedISODate
|
||||
? [
|
||||
{
|
||||
name: "mention",
|
||||
icon: <CalendarIcon />,
|
||||
title: dateToRelativeReadable(parsedISODate, t, userLocale),
|
||||
subtitle: dateToReadable(parsedISODate, userLocale),
|
||||
section: DateSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: parsedISODate,
|
||||
actorId,
|
||||
label: dateToReadable(parsedISODate, userLocale),
|
||||
},
|
||||
} as MentionItem,
|
||||
]
|
||||
: [];
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", {
|
||||
@@ -87,7 +122,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] = actorId
|
||||
const mentionItems: MentionItem[] = actorId
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
@@ -253,9 +288,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
])
|
||||
: [];
|
||||
|
||||
const items: MentionItem[] = [...dateItems, ...mentionItems];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (
|
||||
item.attrs.type === MentionType.Date ||
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
"addressparser": "^1.0.1",
|
||||
"async-sema": "^3.1.1",
|
||||
"bull": "^4.16.5",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"command-score": "^0.1.2",
|
||||
"compressorjs": "^1.3.0",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import { observer } from "mobx-react";
|
||||
import {
|
||||
CalendarIcon,
|
||||
DocumentIcon,
|
||||
EmailIcon,
|
||||
CollectionIcon,
|
||||
@@ -7,9 +9,18 @@ import {
|
||||
} from "outline-icons";
|
||||
import type { Node } from "prosemirror-model";
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import styled from "styled-components";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import { depths, s } from "../../styles";
|
||||
import {
|
||||
dateLocale,
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
} from "../../utils/date";
|
||||
import { Backticks } from "../../components/Backticks";
|
||||
import Flex from "../../components/Flex";
|
||||
import Icon from "../../components/Icon";
|
||||
@@ -510,6 +521,93 @@ export const MentionPullRequest = observer((props: IssuePrProps) => {
|
||||
);
|
||||
});
|
||||
|
||||
type DateProps = ComponentProps & {
|
||||
onChangeDate: (modelId: string, label: string) => void;
|
||||
};
|
||||
|
||||
export const MentionDate = observer(function MentionDate_(props: DateProps) {
|
||||
const { isSelected, isEditable, node, onChangeDate } = props;
|
||||
const { t } = useTranslation();
|
||||
const { auth } = useStores();
|
||||
const theme = useTheme();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
|
||||
|
||||
const language = auth.user?.language;
|
||||
const iso = typeof node.attrs.modelId === "string" ? node.attrs.modelId : "";
|
||||
const display = dateToRelativeReadable(iso, t, language);
|
||||
const selectedDate = parseISODate(iso) ?? undefined;
|
||||
|
||||
const styles = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
"--rdp-caption-font-size": "16px",
|
||||
"--rdp-cell-size": "34px",
|
||||
"--rdp-selected-text": theme.accentText,
|
||||
"--rdp-accent-color": theme.accent,
|
||||
"--rdp-accent-color-dark": theme.accent,
|
||||
"--rdp-background-color": theme.listItemHoverBackground,
|
||||
"--rdp-background-color-dark": theme.listItemHoverBackground,
|
||||
}) as React.CSSProperties,
|
||||
[theme]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
const newIso = toISODate(date);
|
||||
onChangeDate(newIso, dateToReadable(newIso, language));
|
||||
},
|
||||
[onChangeDate, language]
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<DateMention
|
||||
{...attrs}
|
||||
className={cn(className, {
|
||||
"ProseMirror-selectednode": isSelected,
|
||||
})}
|
||||
$editable={isEditable}
|
||||
>
|
||||
<CalendarIcon size={18} />
|
||||
{display}
|
||||
</DateMention>
|
||||
);
|
||||
|
||||
if (!isEditable) {
|
||||
return trigger;
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
|
||||
<PopoverPrimitive.Trigger
|
||||
asChild
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{trigger}
|
||||
</PopoverPrimitive.Trigger>
|
||||
<PopoverPrimitive.Portal>
|
||||
<DatePopoverContent
|
||||
sideOffset={4}
|
||||
align="start"
|
||||
aria-label={t("Choose a date")}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DayPicker
|
||||
required
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
defaultMonth={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
style={styles}
|
||||
locale={dateLocale(language)}
|
||||
/>
|
||||
</DatePopoverContent>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
const MentionLoading = ({ className }: { className: string }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -532,6 +630,33 @@ const MentionError = ({ className }: { className: string }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const DateMention = styled.span<{ $editable: boolean }>`
|
||||
cursor: ${(props) => (props.$editable ? "pointer" : "default")};
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
const DatePopoverContent = styled(PopoverPrimitive.Content)`
|
||||
z-index: ${depths.modal};
|
||||
background: ${s("menuBackground")};
|
||||
box-shadow: ${s("menuShadow")};
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
padding: 6px 12px;
|
||||
|
||||
&[data-state="open"] {
|
||||
animation: fadeIn 150ms ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledWarningIcon = styled(WarningIcon)`
|
||||
margin: 0 -2px;
|
||||
`;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
MentionIssue,
|
||||
MentionProject,
|
||||
MentionPullRequest,
|
||||
MentionDate,
|
||||
MentionURL,
|
||||
MentionUser,
|
||||
} from "../components/Mentions";
|
||||
@@ -95,12 +96,21 @@ export default class Mention extends Node {
|
||||
},
|
||||
],
|
||||
toDOM: (node) => [
|
||||
node.attrs.type === MentionType.User ? "span" : "a",
|
||||
node.attrs.type === MentionType.User ||
|
||||
node.attrs.type === MentionType.Date
|
||||
? "span"
|
||||
: "a",
|
||||
{
|
||||
class: `${node.type.name} use-hover-preview`,
|
||||
// Date mentions are self-contained and have nothing to unfurl, so
|
||||
// they opt out of the hover preview behaviour.
|
||||
class:
|
||||
node.attrs.type === MentionType.Date
|
||||
? node.type.name
|
||||
: `${node.type.name} use-hover-preview`,
|
||||
id: node.attrs.id,
|
||||
href:
|
||||
node.attrs.type === MentionType.User
|
||||
node.attrs.type === MentionType.User ||
|
||||
node.attrs.type === MentionType.Date
|
||||
? undefined
|
||||
: node.attrs.type === MentionType.Document
|
||||
? `${env.URL}/doc/${node.attrs.modelId}`
|
||||
@@ -162,6 +172,10 @@ export default class Mention extends Node {
|
||||
onChangeUnfurl={this.handleChangeUnfurl(props)}
|
||||
/>
|
||||
);
|
||||
case MentionType.Date:
|
||||
return (
|
||||
<MentionDate {...props} onChangeDate={this.handleChangeDate(props)} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -341,6 +355,25 @@ export default class Mention extends Node {
|
||||
};
|
||||
}
|
||||
|
||||
handleChangeDate =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(modelId: string, label: string) => {
|
||||
const { view } = this.editor;
|
||||
const { tr } = view.state;
|
||||
const pos = getPos();
|
||||
|
||||
if (node.attrs.modelId === modelId && node.attrs.label === label) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transaction = tr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
modelId,
|
||||
label,
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
};
|
||||
|
||||
handleChangeUnfurl =
|
||||
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
|
||||
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
|
||||
|
||||
@@ -98,6 +98,21 @@ describe("mention rule", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("date format", () => {
|
||||
it("should parse a date mention with an ISO date modelId", () => {
|
||||
const result = md.parse(
|
||||
"@[February 3rd, 2024](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03)",
|
||||
{}
|
||||
);
|
||||
const mentions = findMentionTokens(result);
|
||||
|
||||
expect(mentions).toHaveLength(1);
|
||||
expect(mentions[0].type).toBe("date");
|
||||
expect(mentions[0].modelId).toBe("2024-02-03");
|
||||
expect(mentions[0].label).toBe("February 3rd, 2024");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed content", () => {
|
||||
it("should parse mention within text", () => {
|
||||
const result = md.parse(
|
||||
|
||||
@@ -121,6 +121,7 @@ export enum MentionType {
|
||||
PullRequest = "pull_request",
|
||||
Project = "project",
|
||||
URL = "url",
|
||||
Date = "date",
|
||||
}
|
||||
|
||||
export type PublicEnv = {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
} from "./date";
|
||||
|
||||
describe("toISODate / parseISODate", () => {
|
||||
it("round-trips a date through its ISO representation", () => {
|
||||
const date = new Date(2024, 1, 3); // Feb 3, 2024
|
||||
const iso = toISODate(date);
|
||||
expect(iso).toBe("2024-02-03");
|
||||
expect(parseISODate(iso)).toEqual(date);
|
||||
});
|
||||
|
||||
it("returns null for an invalid ISO string", () => {
|
||||
expect(parseISODate("not-a-date")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateToReadable", () => {
|
||||
it("formats an absolute, human-readable date", () => {
|
||||
expect(dateToReadable("2024-02-03")).toBe("February 3rd, 2024");
|
||||
});
|
||||
|
||||
it("returns the original string when invalid", () => {
|
||||
expect(dateToReadable("nonsense")).toBe("nonsense");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dateToRelativeReadable", () => {
|
||||
const t = (key: string) => key;
|
||||
|
||||
it("returns Today for the current date", () => {
|
||||
const iso = toISODate(new Date());
|
||||
expect(dateToRelativeReadable(iso, t)).toBe("Today");
|
||||
});
|
||||
|
||||
it("returns Tomorrow for the next day", () => {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
expect(dateToRelativeReadable(toISODate(tomorrow), t)).toBe("Tomorrow");
|
||||
});
|
||||
|
||||
it("returns Yesterday for the previous day", () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
expect(dateToRelativeReadable(toISODate(yesterday), t)).toBe("Yesterday");
|
||||
});
|
||||
|
||||
it("omits the year within the current year", () => {
|
||||
const date = new Date();
|
||||
date.setMonth(date.getMonth() === 0 ? 6 : 0); // a different month, same year
|
||||
date.setDate(15);
|
||||
const result = dateToRelativeReadable(toISODate(date), t);
|
||||
expect(result).not.toContain(`${date.getFullYear()}`);
|
||||
});
|
||||
|
||||
it("includes the year for a date in a different year", () => {
|
||||
expect(dateToRelativeReadable("2020-02-03", t)).toBe("February 3rd, 2020");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,13 @@
|
||||
import type { Locale } from "date-fns";
|
||||
import {
|
||||
addSeconds,
|
||||
format,
|
||||
formatDistanceToNow,
|
||||
isSameYear,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
isYesterday,
|
||||
parseISO,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
@@ -297,3 +303,83 @@ export function dateLocale(language: keyof typeof locales | undefined | null) {
|
||||
}
|
||||
|
||||
export { locales };
|
||||
|
||||
/**
|
||||
* Formats a Date into a date-only ISO string (yyyy-MM-dd) in the local
|
||||
* timezone. Used as the stored value for date mentions.
|
||||
*
|
||||
* @param date The date to format.
|
||||
* @returns the date-only ISO string.
|
||||
*/
|
||||
export function toISODate(date: Date): string {
|
||||
return format(date, "yyyy-MM-dd");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight.
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @returns the parsed Date, or null when the string is not a valid date.
|
||||
*/
|
||||
export function parseISODate(iso: string): Date | null {
|
||||
const date = parseISO(iso);
|
||||
return isValid(date) ? date : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date mention's stored ISO value into an absolute, localized,
|
||||
* human-readable label (e.g. "January 2nd, 2024"). Suitable for plaintext
|
||||
* and markdown serialization where a stable, unambiguous value is required.
|
||||
*
|
||||
* @param iso The date-only ISO string.
|
||||
* @param language The user's language preference.
|
||||
* @returns the absolute human-readable date, or the original string when invalid.
|
||||
*/
|
||||
export function dateToReadable(
|
||||
iso: string,
|
||||
language?: keyof typeof locales | null
|
||||
): string {
|
||||
const date = parseISODate(iso);
|
||||
if (!date) {
|
||||
return iso;
|
||||
}
|
||||
return format(date, "MMMM do, yyyy", { locale: dateLocale(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.
|
||||
*
|
||||
* @param iso The date-only 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.
|
||||
*/
|
||||
export function dateToRelativeReadable(
|
||||
iso: string,
|
||||
t: (key: string) => string,
|
||||
language?: keyof typeof locales | null
|
||||
): string {
|
||||
const date = parseISODate(iso);
|
||||
if (!date) {
|
||||
return iso;
|
||||
}
|
||||
|
||||
if (isToday(date)) {
|
||||
return t("Today");
|
||||
}
|
||||
if (isTomorrow(date)) {
|
||||
return t("Tomorrow");
|
||||
}
|
||||
if (isYesterday(date)) {
|
||||
return t("Yesterday");
|
||||
}
|
||||
|
||||
const locale = dateLocale(language);
|
||||
if (isSameYear(date, new Date())) {
|
||||
return format(date, "MMMM do", { locale });
|
||||
}
|
||||
return format(date, "MMMM do, yyyy", { locale });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { parseNaturalLanguageDate } from "./parseNaturalLanguageDate";
|
||||
|
||||
describe("parseNaturalLanguageDate", () => {
|
||||
const reference = new Date(2024, 0, 1); // Mon Jan 1, 2024
|
||||
|
||||
it("returns null for empty input", () => {
|
||||
expect(parseNaturalLanguageDate("", reference)).toBeNull();
|
||||
expect(parseNaturalLanguageDate(" ", reference)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for non-date input", () => {
|
||||
expect(parseNaturalLanguageDate("hello world", reference)).toBeNull();
|
||||
});
|
||||
|
||||
it("parses 'today'", () => {
|
||||
const result = parseNaturalLanguageDate("today", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 1));
|
||||
});
|
||||
|
||||
it("parses 'tomorrow'", () => {
|
||||
const result = parseNaturalLanguageDate("tomorrow", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 2));
|
||||
});
|
||||
|
||||
it("parses 'yesterday'", () => {
|
||||
const result = parseNaturalLanguageDate("yesterday", reference);
|
||||
expect(result).toEqual(new Date(2023, 11, 31));
|
||||
});
|
||||
|
||||
it("parses 'in 3 days'", () => {
|
||||
const result = parseNaturalLanguageDate("in 3 days", reference);
|
||||
expect(result).toEqual(new Date(2024, 0, 4));
|
||||
});
|
||||
|
||||
it("parses an explicit month and day", () => {
|
||||
const result = parseNaturalLanguageDate("February 3", reference);
|
||||
expect(result).toEqual(new Date(2024, 1, 3));
|
||||
});
|
||||
|
||||
it("normalizes the time component to local midnight", () => {
|
||||
const result = parseNaturalLanguageDate("tomorrow at 5pm", reference);
|
||||
expect(result?.getHours()).toBe(0);
|
||||
expect(result?.getMinutes()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import * as chrono from "chrono-node";
|
||||
|
||||
/**
|
||||
* Parse a natural language string such as "tomorrow", "next friday",
|
||||
* "jan 2" or "in 3 days" 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.
|
||||
*
|
||||
* @param input the natural language string to parse.
|
||||
* @param referenceDate the date relative to which terms like "tomorrow"
|
||||
* are resolved, defaults to now.
|
||||
* @returns the matched date with the time set to local midnight, or null
|
||||
* when no date could be confidently parsed.
|
||||
*/
|
||||
export function parseNaturalLanguageDate(
|
||||
input: string,
|
||||
referenceDate: Date = new Date()
|
||||
): Date | null {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results = chrono.parse(trimmed, referenceDate, { forwardDate: true });
|
||||
const result = results[0];
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only accept matches that span (roughly) the whole input so that
|
||||
// unrelated text typed after "@" does not accidentally resolve to a date.
|
||||
if (result.text.trim().length < trimmed.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = result.start.date();
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
@@ -8682,6 +8682,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"chrono-node@npm:^2.9.1":
|
||||
version: 2.9.1
|
||||
resolution: "chrono-node@npm:2.9.1"
|
||||
checksum: 10c0/2408e4a404ea3e7e8226daae75cdbc4621bc66065d596574440d0f81a7c456c166ca212c9e1c329e653c716300289498794fece2102f9365bfd33bd1a4b24473
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ci-info@npm:^3.7.0":
|
||||
version: 3.9.0
|
||||
resolution: "ci-info@npm:3.9.0"
|
||||
@@ -15178,6 +15185,7 @@ __metadata:
|
||||
babel-plugin-transform-typescript-metadata: "npm:^0.4.0"
|
||||
browserslist-to-esbuild: "npm:^1.2.0"
|
||||
bull: "npm:^4.16.5"
|
||||
chrono-node: "npm:^2.9.1"
|
||||
class-validator: "npm:^0.15.1"
|
||||
command-score: "npm:^0.1.2"
|
||||
compressorjs: "npm:^1.3.0"
|
||||
|
||||
Reference in New Issue
Block a user