diff --git a/app/components/PaginatedList.tsx b/app/components/PaginatedList.tsx index 3f4911df28..4a37c8d8ff 100644 --- a/app/components/PaginatedList.tsx +++ b/app/components/PaginatedList.tsx @@ -11,7 +11,7 @@ import ArrowKeyNavigation from "~/components/ArrowKeyNavigation"; import DelayedMount from "~/components/DelayedMount"; import PlaceholderList from "~/components/List/Placeholder"; import withStores from "~/components/withStores"; -import { dateToHeading } from "~/utils/dates"; +import { dateToHeading } from "~/utils/date"; export interface PaginatedItem { id: string; diff --git a/app/menus/TemplatesMenu.tsx b/app/menus/TemplatesMenu.tsx index 97c4e9de09..ed8a1685a0 100644 --- a/app/menus/TemplatesMenu.tsx +++ b/app/menus/TemplatesMenu.tsx @@ -9,7 +9,9 @@ import Button from "~/components/Button"; import ContextMenu from "~/components/ContextMenu"; import MenuItem from "~/components/ContextMenu/MenuItem"; import Separator from "~/components/ContextMenu/Separator"; +import useCurrentUser from "~/hooks/useCurrentUser"; import useStores from "~/hooks/useStores"; +import { replaceTitleVariables } from "~/utils/date"; type Props = { document: Document; @@ -20,6 +22,7 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) { const menu = useMenuState({ modal: true, }); + const user = useCurrentUser(); const { documents } = useStores(); const { t } = useTranslation(); const templates = documents.templates; @@ -43,7 +46,9 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) { {...menu} > - {template.titleWithDefault} + + {replaceTitleVariables(template.titleWithDefault, user)} +
{t("By {{ author }}", { @@ -76,6 +81,9 @@ function TemplatesMenu({ onSelectTemplate, document }: Props) { const TemplateItem = styled.div` text-align: left; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const Author = styled.div` diff --git a/app/models/Document.ts b/app/models/Document.ts index 8c37451bef..cf8b42bebc 100644 --- a/app/models/Document.ts +++ b/app/models/Document.ts @@ -23,10 +23,6 @@ export default class Document extends ParanoidModel { constructor(fields: Record, store: DocumentsStore) { super(fields, store); - if (this.isPersistedOnce && this.isFromTemplate) { - this.title = ""; - } - this.embedsDisabled = Storage.get(`embedsDisabled-${this.id}`) ?? false; autorun(() => { diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index d8ad3f0333..41614370f1 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -34,6 +34,7 @@ import withStores from "~/components/withStores"; import type { Editor as TEditor } from "~/editor"; import { NavigationNode } from "~/types"; import { client } from "~/utils/ApiClient"; +import { replaceTitleVariables } from "~/utils/date"; import { emojiToUrl } from "~/utils/emoji"; import { isModKey } from "~/utils/keyboard"; import { @@ -186,8 +187,12 @@ class DocumentScene extends React.Component { } if (!this.title) { - this.title = template.title; - this.props.document.title = template.title; + const title = replaceTitleVariables( + template.title, + this.props.auth.user || undefined + ); + this.title = title; + this.props.document.title = title; } this.props.document.text = template.text; diff --git a/app/utils/dates.ts b/app/utils/date.ts similarity index 62% rename from app/utils/dates.ts rename to app/utils/date.ts index e6165e3096..1a49d3388f 100644 --- a/app/utils/dates.ts +++ b/app/utils/date.ts @@ -6,7 +6,15 @@ import { differenceInCalendarYears, format as formatDate, } from "date-fns"; +import { startCase } from "lodash"; import { TFunction } from "react-i18next"; +import { + getCurrentDateAsString, + getCurrentDateTimeAsString, + getCurrentTimeAsString, + unicodeCLDRtoBCP47, +} from "@shared/utils/date"; +import User from "~/models/User"; import { dateLocale } from "~/utils/i18n"; export function dateToHeading( @@ -62,3 +70,21 @@ export function dateToHeading( locale, }); } + +/** + * Replaces template variables in the given text with the current date and time. + * + * @param text The text to replace the variables in + * @param user The user to get the language/locale from + * @returns The text with the variables replaced + */ +export function replaceTitleVariables(text: string, user?: User) { + const locales = user?.language + ? unicodeCLDRtoBCP47(user.language) + : undefined; + + return text + .replace("{date}", startCase(getCurrentDateAsString(locales))) + .replace("{time}", startCase(getCurrentTimeAsString(locales))) + .replace("{datetime}", startCase(getCurrentDateTimeAsString(locales))); +} diff --git a/app/utils/language.ts b/app/utils/language.ts index c7078542aa..56e4a21442 100644 --- a/app/utils/language.ts +++ b/app/utils/language.ts @@ -1,4 +1,5 @@ import { i18n } from "i18next"; +import { unicodeCLDRtoBCP47 } from "@shared/utils/date"; import Desktop from "./Desktop"; export function detectLanguage() { @@ -14,7 +15,7 @@ export function changeLanguage( if (toLanguageString && i18n.language !== toLanguageString) { // Languages are stored in en_US format in the database, however the // frontend translation framework (i18next) expects en-US - const locale = toLanguageString.replace("_", "-"); + const locale = unicodeCLDRtoBCP47(toLanguageString); i18n.changeLanguage(locale); Desktop.bridge?.setSpellCheckerLanguages(["en-US", locale]); diff --git a/server/commands/documentCreator.ts b/server/commands/documentCreator.ts index dad344f244..6cb097b546 100644 --- a/server/commands/documentCreator.ts +++ b/server/commands/documentCreator.ts @@ -1,5 +1,26 @@ import { Transaction } from "sequelize"; import { Document, Event, User } from "@server/models"; +import DocumentHelper from "@server/models/helpers/DocumentHelper"; + +type Props = { + id?: string; + title: string; + text: string; + publish?: boolean; + collectionId?: string; + parentDocumentId?: string | null; + importId?: string; + templateDocument?: Document | null; + publishedAt?: Date; + template?: boolean; + createdAt?: Date; + updatedAt?: Date; + user: User; + editorVersion?: string; + source?: "import"; + ip?: string; + transaction: Transaction; +}; export default async function documentCreator({ title = "", @@ -20,25 +41,7 @@ export default async function documentCreator({ source, ip, transaction, -}: { - id?: string; - title: string; - text: string; - publish?: boolean; - collectionId?: string; - parentDocumentId?: string | null; - importId?: string; - templateDocument?: Document | null; - publishedAt?: Date; - template?: boolean; - createdAt?: Date; - updatedAt?: Date; - user: User; - editorVersion?: string; - source?: "import"; - ip?: string; - transaction: Transaction; -}): Promise { +}: Props): Promise { const templateId = templateDocument ? templateDocument.id : undefined; const document = await Document.create( { @@ -56,7 +59,9 @@ export default async function documentCreator({ templateId, publishedAt, importId, - title: templateDocument ? templateDocument.title : title, + title: templateDocument + ? DocumentHelper.replaceTemplateVariables(templateDocument.title, user) + : title, text: templateDocument ? templateDocument.text : text, }, { diff --git a/server/models/helpers/DocumentHelper.tsx b/server/models/helpers/DocumentHelper.tsx index 74c3dab06c..fbebef9bb4 100644 --- a/server/models/helpers/DocumentHelper.tsx +++ b/server/models/helpers/DocumentHelper.tsx @@ -3,7 +3,7 @@ import { yDocToProsemirrorJSON, } from "@getoutline/y-prosemirror"; import { JSDOM } from "jsdom"; -import { escapeRegExp } from "lodash"; +import { escapeRegExp, startCase } from "lodash"; import { Node, DOMSerializer } from "prosemirror-model"; import * as React from "react"; import { renderToString } from "react-dom/server"; @@ -12,12 +12,19 @@ import * as Y from "yjs"; import EditorContainer from "@shared/editor/components/Styles"; import GlobalStyles from "@shared/styles/globals"; import light from "@shared/styles/theme"; +import { + getCurrentDateAsString, + getCurrentDateTimeAsString, + getCurrentTimeAsString, + unicodeCLDRtoBCP47, +} from "@shared/utils/date"; import { isRTL } from "@shared/utils/rtl"; import unescape from "@shared/utils/unescape"; import { parser, schema } from "@server/editor"; import Logger from "@server/logging/Logger"; import Document from "@server/models/Document"; import type Revision from "@server/models/Revision"; +import User from "@server/models/User"; import diff from "@server/utils/diff"; import parseAttachmentIds from "@server/utils/parseAttachmentIds"; import { getSignedUrl } from "@server/utils/s3"; @@ -307,6 +314,24 @@ export default class DocumentHelper { return text; } + /** + * Replaces template variables in the given text with the current date and time. + * + * @param text The text to replace the variables in + * @param user The user to get the language/locale from + * @returns The text with the variables replaced + */ + static replaceTemplateVariables(text: string, user: User) { + const locales = user.language + ? unicodeCLDRtoBCP47(user.language) + : undefined; + + return text + .replace("{date}", startCase(getCurrentDateAsString(locales))) + .replace("{time}", startCase(getCurrentTimeAsString(locales))) + .replace("{datetime}", startCase(getCurrentDateTimeAsString(locales))); + } + /** * Applies the given Markdown to the document, this essentially creates a * single change in the collaborative state that makes all the edits to get diff --git a/shared/i18n/index.ts b/shared/i18n/index.ts index a743349f68..c2e081a3ae 100644 --- a/shared/i18n/index.ts +++ b/shared/i18n/index.ts @@ -1,6 +1,7 @@ import i18n from "i18next"; import backend from "i18next-http-backend"; import { initReactI18next } from "react-i18next"; +import { unicodeBCP47toCLDR, unicodeCLDRtoBCP47 } from "../utils/date"; // Note: Updating the available languages? Make sure to also update the // locales array in app/utils/i18n.js to enable translation for timestamps. @@ -77,14 +78,8 @@ export const languageOptions = [ export const languages: string[] = languageOptions.map((i) => i.value); -// Languages are stored in en_US format in the database, however the -// frontend translation framework (i18next) expects en-US -const underscoreToDash = (text: string) => text.replace("_", "-"); - -const dashToUnderscore = (text: string) => text.replace("-", "_"); - export const initI18n = (defaultLanguage = "en_US") => { - const lng = underscoreToDash(defaultLanguage); + const lng = unicodeCLDRtoBCP47(defaultLanguage); i18n .use(backend) .use(initReactI18next) @@ -94,7 +89,7 @@ export const initI18n = (defaultLanguage = "en_US") => { // this must match the path defined in routes. It's the path that the // frontend UI code will hit to load missing translations. loadPath: (languages: string[]) => - `/locales/${dashToUnderscore(languages[0])}.json`, + `/locales/${unicodeBCP47toCLDR(languages[0])}.json`, }, interpolation: { escapeValue: false, @@ -104,7 +99,7 @@ export const initI18n = (defaultLanguage = "en_US") => { }, lng, fallbackLng: lng, - supportedLngs: languages.map(underscoreToDash), + supportedLngs: languages.map(unicodeCLDRtoBCP47), // Uncomment when debugging translation framework, otherwise it's noisy keySeparator: false, }); diff --git a/shared/utils/date.ts b/shared/utils/date.ts index 98d5f89c87..67b3993ce4 100644 --- a/shared/utils/date.ts +++ b/shared/utils/date.ts @@ -20,13 +20,33 @@ export function subtractDate(date: Date, period: DateFilter) { } } +/** + * 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"); +} + /** * Returns the current date as a string formatted depending on current locale. * * @returns The current date */ -export function getCurrentDateAsString() { - return new Date().toLocaleDateString(undefined, { +export function getCurrentDateAsString(locales?: Intl.LocalesArgument) { + return new Date().toLocaleDateString(locales, { year: "numeric", month: "long", day: "numeric", @@ -38,8 +58,8 @@ export function getCurrentDateAsString() { * * @returns The current time */ -export function getCurrentTimeAsString() { - return new Date().toLocaleTimeString(undefined, { +export function getCurrentTimeAsString(locales?: Intl.LocalesArgument) { + return new Date().toLocaleTimeString(locales, { hour: "numeric", minute: "numeric", }); @@ -51,8 +71,8 @@ export function getCurrentTimeAsString() { * * @returns The current date and time */ -export function getCurrentDateTimeAsString() { - return new Date().toLocaleString(undefined, { +export function getCurrentDateTimeAsString(locales?: Intl.LocalesArgument) { + return new Date().toLocaleString(locales, { year: "numeric", month: "long", day: "numeric",