mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
3446fe8ff7
dateToReadable now formats current-year dates without the year (e.g. "June 8th") and includes it otherwise (e.g. "February 3rd, 2024"). This keeps the mention menu subtitle compact while the relative title shows "Today"/"Tomorrow".
391 lines
8.8 KiB
TypeScript
391 lines
8.8 KiB
TypeScript
/* oxlint-disable import/no-duplicates */
|
|
import type { Locale } from "date-fns";
|
|
import {
|
|
addSeconds,
|
|
format,
|
|
formatDistanceToNow,
|
|
isSameYear,
|
|
isToday,
|
|
isTomorrow,
|
|
isYesterday,
|
|
parseISO,
|
|
subDays,
|
|
subMonths,
|
|
subWeeks,
|
|
subYears,
|
|
isValid,
|
|
parse,
|
|
} from "date-fns";
|
|
import {
|
|
ca,
|
|
cs,
|
|
de,
|
|
enGB,
|
|
enUS,
|
|
es,
|
|
faIR,
|
|
fr,
|
|
he,
|
|
hu,
|
|
it,
|
|
ja,
|
|
ko,
|
|
nb,
|
|
nl,
|
|
ptBR,
|
|
pt,
|
|
pl,
|
|
sv,
|
|
tr,
|
|
vi,
|
|
uk,
|
|
zhCN,
|
|
zhTW,
|
|
} from "date-fns/locale";
|
|
import type { DateFilter } from "../types";
|
|
import { isBrowser } from "./browser";
|
|
|
|
/**
|
|
* Determines if the user's locale uses month-first date format (MM/dd).
|
|
*
|
|
* @returns true if locale uses MM/dd format, false for dd/MM format.
|
|
*/
|
|
export function usesMonthFirstFormat(): boolean {
|
|
if (!isBrowser || typeof Intl === "undefined") {
|
|
return false;
|
|
}
|
|
|
|
// Format a known date and check if month comes before day
|
|
const formatted = new Intl.DateTimeFormat(undefined, {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
}).format(new Date(2000, 11, 25)); // Dec 25, 2000
|
|
|
|
// If it starts with "12", month comes first
|
|
return formatted.startsWith("12");
|
|
}
|
|
|
|
/**
|
|
* Attempts to parse a date string in various common formats.
|
|
*
|
|
* @param dateStr The date string to parse.
|
|
* @returns a Date object if parsing is successful, null otherwise.
|
|
*/
|
|
export function parseDate(dateStr: string): Date | null {
|
|
if (!dateStr) {
|
|
return null;
|
|
}
|
|
|
|
// Remove any trailing alphabetic text (e.g., "Uhr", "at", "o'clock", etc.)
|
|
const cleaned = dateStr.trim().replace(/\s*[a-zA-Z]+\s*$/, "");
|
|
|
|
const monthFirst = [
|
|
"MM/dd/yyyy HH:mm:ss",
|
|
"MM/dd/yyyy HH:mm",
|
|
"MM/dd/yyyy",
|
|
"MM/dd HH:mm:ss",
|
|
"MM/dd HH:mm",
|
|
"MM/dd",
|
|
];
|
|
|
|
const dayFirst = [
|
|
"dd/MM/yyyy HH:mm:ss",
|
|
"dd/MM/yyyy HH:mm",
|
|
"dd/MM/yyyy",
|
|
"dd/MM HH:mm:ss",
|
|
"dd/MM HH:mm",
|
|
"dd/MM",
|
|
];
|
|
|
|
// Ambiguous slash formats - order based on user's locale
|
|
const slashFormats = usesMonthFirstFormat()
|
|
? [...monthFirst, ...dayFirst]
|
|
: [...dayFirst, ...monthFirst];
|
|
|
|
// Common date formats used in tables (with and without time, with and without year)
|
|
const formats = [
|
|
// ISO formats
|
|
"yyyy-MM-dd HH:mm:ss",
|
|
"yyyy-MM-dd HH:mm",
|
|
"yyyy-MM-dd",
|
|
// European dot formats
|
|
"dd.MM.yyyy HH:mm:ss",
|
|
"dd.MM.yyyy HH:mm",
|
|
"dd.MM.yyyy",
|
|
"dd.MM. HH:mm:ss",
|
|
"dd.MM. HH:mm",
|
|
"dd.MM.",
|
|
"d.M.yyyy HH:mm:ss",
|
|
"d.M.yyyy HH:mm",
|
|
"d.M.yyyy",
|
|
"d.M. HH:mm:ss",
|
|
"d.M. HH:mm",
|
|
"d.M.",
|
|
// Locale-dependent slash formats
|
|
...slashFormats,
|
|
];
|
|
|
|
const referenceDate = new Date();
|
|
|
|
for (const format of formats) {
|
|
const date = parse(cleaned, format, referenceDate);
|
|
if (isValid(date)) {
|
|
return date;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function subtractDate(date: Date, period: DateFilter) {
|
|
switch (period) {
|
|
case "day":
|
|
return subDays(date, 1);
|
|
|
|
case "week":
|
|
return subWeeks(date, 1);
|
|
|
|
case "month":
|
|
return subMonths(date, 1);
|
|
|
|
case "year":
|
|
return subYears(date, 1);
|
|
|
|
default:
|
|
return date;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns a humanized relative time string for the given date.
|
|
*
|
|
* @param date The date to convert
|
|
* @param options The options to pass to date-fns
|
|
* @returns The relative time string
|
|
*/
|
|
export function dateToRelative(
|
|
date: Date | number,
|
|
options?: {
|
|
includeSeconds?: boolean;
|
|
addSuffix?: boolean;
|
|
locale?: Locale | undefined;
|
|
shorten?: boolean;
|
|
}
|
|
) {
|
|
const now = new Date();
|
|
const parsedDateTime = new Date(date);
|
|
|
|
// Protect against "in less than a minute" when users computer clock is off.
|
|
const normalizedDateTime =
|
|
parsedDateTime > now && parsedDateTime < addSeconds(now, 60)
|
|
? now
|
|
: parsedDateTime;
|
|
|
|
const output = formatDistanceToNow(normalizedDateTime, options);
|
|
|
|
// Some tweaks to make english language shorter.
|
|
if (options?.shorten) {
|
|
return output
|
|
.replace("about", "")
|
|
.replace("less than a minute ago", "just now")
|
|
.replace("minute", "min");
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
/**
|
|
* Converts a locale string from Unicode CLDR format to BCP47 format.
|
|
*
|
|
* @param locale The locale string to convert
|
|
* @returns The converted locale string
|
|
*/
|
|
export function unicodeCLDRtoBCP47(locale: string) {
|
|
return locale.replace("_", "-").replace("root", "und");
|
|
}
|
|
|
|
/**
|
|
* Converts a locale string from BCP47 format to Unicode CLDR format.
|
|
*
|
|
* @param locale The locale string to convert
|
|
* @returns The converted locale string
|
|
*/
|
|
export function unicodeBCP47toCLDR(locale: string) {
|
|
return locale.replace("-", "_").replace("und", "root");
|
|
}
|
|
|
|
/**
|
|
* Converts a locale string from Unicode CLDR format to ISO 639 format.
|
|
*
|
|
* @param locale The locale string to convert
|
|
* @returns The converted locale string
|
|
*/
|
|
export function unicodeCLDRtoISO639(locale: string) {
|
|
return locale.split("_")[0];
|
|
}
|
|
|
|
/**
|
|
* Returns the current date as a string formatted depending on current locale.
|
|
*
|
|
* @returns The current date
|
|
*/
|
|
export function getCurrentDateAsString(locale?: Intl.LocalesArgument) {
|
|
return new Date().toLocaleDateString(locale, {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the current time as a string formatted depending on current locale.
|
|
*
|
|
* @returns The current time
|
|
*/
|
|
export function getCurrentTimeAsString(locale?: Intl.LocalesArgument) {
|
|
return new Date().toLocaleTimeString(locale, {
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Returns the current date and time as a string formatted depending on current
|
|
* locale.
|
|
*
|
|
* @returns The current date and time
|
|
*/
|
|
export function getCurrentDateTimeAsString(locale?: Intl.LocalesArgument) {
|
|
return new Date().toLocaleString(locale, {
|
|
year: "numeric",
|
|
month: "long",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
});
|
|
}
|
|
|
|
const locales = {
|
|
ca_ES: ca,
|
|
cs_CZ: cs,
|
|
de_DE: de,
|
|
en_GB: enGB,
|
|
en_US: enUS,
|
|
es_ES: es,
|
|
fa_IR: faIR,
|
|
fr_FR: fr,
|
|
he_IL: he,
|
|
hu_HU: hu,
|
|
it_IT: it,
|
|
ja_JP: ja,
|
|
ko_KR: ko,
|
|
nb_NO: nb,
|
|
nl_NL: nl,
|
|
pt_BR: ptBR,
|
|
pt_PT: pt,
|
|
pl_PL: pl,
|
|
sv_SE: sv,
|
|
tr_TR: tr,
|
|
uk_UA: uk,
|
|
vi_VN: vi,
|
|
zh_CN: zhCN,
|
|
zh_TW: zhTW,
|
|
};
|
|
|
|
/**
|
|
* Returns the date-fns locale object for the given user language preference.
|
|
*
|
|
* @param language The user language
|
|
* @returns The date-fns locale.
|
|
*/
|
|
export function dateLocale(language: keyof typeof locales | undefined | null) {
|
|
return language ? locales[language] : undefined;
|
|
}
|
|
|
|
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. 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.
|
|
*
|
|
* @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;
|
|
}
|
|
const locale = dateLocale(language);
|
|
if (isSameYear(date, new Date())) {
|
|
return format(date, "MMMM do", { locale });
|
|
}
|
|
return format(date, "MMMM do, yyyy", { locale });
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|