diff options
Diffstat (limited to 'src')
-rwxr-xr-x | src/app.ts | 208 | ||||
-rw-r--r-- | src/helpers.ts | 93 | ||||
-rw-r--r-- | src/lib/config.ts | 34 | ||||
-rw-r--r-- | src/lib/email.ts | 71 | ||||
-rw-r--r-- | src/lib/event.ts | 4 | ||||
-rw-r--r-- | src/lib/middleware.ts | 2 | ||||
-rwxr-xr-x | src/routes.js | 15 | ||||
-rw-r--r-- | src/routes/event.ts | 25 | ||||
-rw-r--r-- | src/routes/frontend.ts | 101 | ||||
-rw-r--r-- | src/routes/magicLink.ts | 9 | ||||
-rw-r--r-- | src/types/i18next-fs-backend.d.ts | 5 | ||||
-rw-r--r-- | src/util/validation.ts | 41 |
12 files changed, 404 insertions, 204 deletions
@@ -1,6 +1,20 @@ import express from "express"; import cookieParser from "cookie-parser"; import { create as createHandlebars, ExpressHandlebars } from "express-handlebars"; +import i18next from "i18next"; +import Backend from "i18next-fs-backend"; +import { LanguageDetector, handle } from 'i18next-http-middleware'; +import { createRequire } from 'module'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import path from 'path'; + +const require = createRequire(import.meta.url); +const handlebarsI18next = require('handlebars-i18next'); + +// Recreate __dirname in ES module +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); import routes from "./routes.js"; import frontend from "./routes/frontend.js"; @@ -9,76 +23,150 @@ import event from "./routes/event.js"; import group from "./routes/group.js"; import staticPages from "./routes/static.js"; import magicLink from "./routes/magicLink.js"; +import { getI18nHelpers } from "./helpers.js"; import { activityPubContentType, alternateActivityPubContentType, } from "./lib/activitypub.js"; +import moment from "moment"; import { EmailService } from "./lib/email.js"; import getConfig from "./lib/config.js"; const app = express(); const config = getConfig(); -const hbsInstance = createHandlebars({ - defaultLayout: "main", - partialsDir: ["views/partials/"], - layoutsDir: "views/layouts/", - helpers: { - plural: function (number: number, text: string) { - const singular = number === 1; - // If no text parameter was given, just return a conditional s. - if (typeof text !== "string") return singular ? "" : "s"; - // Split with regex into group1/group2 or group1(group3) - const match = text.match(/^([^()\/]+)(?:\/(.+))?(?:\((\w+)\))?/); - // If no match, just append a conditional s. - if (!match) return text + (singular ? "" : "s"); - // We have a good match, so fire away - return ( - (singular && match[1]) || // Singular case - match[2] || // Plural case: 'bagel/bagels' --> bagels - match[1] + (match[3] || "s") - ); // Plural case: 'bagel(s)' or 'bagel' --> bagels - }, - json: function (context: object) { - return JSON.stringify(context); +// function to construct __dirname with ES module +const getLocalesPath = () => { + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + return path.join(__dirname, '..', 'locales'); +}; + +async function initializeApp() { + // Cookies // + app.use(cookieParser()); + + // i18next configuration + await i18next + .use(Backend) + .use(LanguageDetector) + .init({ + backend: { + loadPath: path.join(getLocalesPath(), '{{lng}}.json'), + }, + fallbackLng: 'en', + preload: ['en', 'ja'], + supportedLngs: ['en', 'ja'], + nonExplicitSupportedLngs: true, + load: 'languageOnly', + debug: false, + detection: { + order: ['header', 'cookie'], + lookupHeader: 'accept-language', + lookupCookie: 'i18next', + caches: ['cookie'] + }, + interpolation: { + escapeValue: false + } + }); + + app.use(handle(i18next)); + + // to Switch language + app.use((req, res, next) => { + const currentLanguage = i18next.language; + i18next.changeLanguage(req.language); + const newLanguage = i18next.language; +// Uncomment for debugging +// console.log('Language Change:', { +// header: req.headers['accept-language'], +// detected: req.language, +// currentLanguage: currentLanguage, +// newLanguage: newLanguage +// }); + next(); + }); + +// Uncomment for debugging +// app.use((req, res, next) => { +// console.log('Language Detection:', { +// header: req.headers['accept-language'], +// detected: req.language, +// i18next: i18next.language +// }); +// next(); +// }); + + // View engine // + const hbsInstance = createHandlebars({ + defaultLayout: "main", + partialsDir: ["views/partials/"], + layoutsDir: "views/layouts/", + helpers: { + // add i18next helpers + ...getI18nHelpers(), + plural: function (key: string, count: number, options: any) { // Register the plural helper + const translation = i18next.t(key, { count: count }); + return translation; + }, + json: function (context: object) { + return JSON.stringify(context); + } }, - }, -}); - -const emailService = new EmailService(config, hbsInstance); -emailService.verify(); - -app.use((req: express.Request, _: express.Response, next: express.NextFunction) => { - req.hbsInstance = hbsInstance; - req.emailService = emailService; - next() - return -}) - -// View engine // -app.engine("handlebars", hbsInstance.engine); -app.set("view engine", "handlebars"); -app.set("hbsInstance", hbsInstance); - -// Static files // -app.use(express.static("public")); - -// Body parser // -app.use(express.json({ type: alternateActivityPubContentType })); -app.use(express.json({ type: activityPubContentType })); -app.use(express.json({ type: "application/json" })); -app.use(express.urlencoded({ extended: true })); - -// Cookies // -app.use(cookieParser()); - -// Router // -app.use("/", staticPages); -app.use("/", frontend); -app.use("/", activitypub); -app.use("/", event); -app.use("/", group); -app.use("/", magicLink); -app.use("/", routes); + }); + + const emailService = new EmailService(config, hbsInstance); + emailService.verify(); + + app.use((req: express.Request, _: express.Response, next: express.NextFunction) => { + req.hbsInstance = hbsInstance; + req.emailService = emailService; + next() + return + }) + + // View engine // + app.engine("handlebars", hbsInstance.engine); + app.set("view engine", "handlebars"); + app.set("hbsInstance", hbsInstance); + + // calling i18nextHelper + if (typeof handlebarsI18next === 'function') { + handlebarsI18next(hbsInstance.handlebars, i18next); + } else if (typeof handlebarsI18next.default === 'function') { + handlebarsI18next.default(hbsInstance.handlebars, i18next); + } else { + console.error('handlebars-i18next helper is not properly loaded'); + } + + i18next.on('languageChanged', function(lng) { + moment.locale(lng); + }); + + app.engine("handlebars", hbsInstance.engine); + app.set("view engine", "handlebars"); + app.set("hbsInstance", hbsInstance); + + // Static files // + app.use(express.static("public")); + + // Body parser // + app.use(express.json({ type: alternateActivityPubContentType })); + app.use(express.json({ type: activityPubContentType })); + app.use(express.json({ type: "application/json" })); + app.use(express.urlencoded({ extended: true })); + + // Router // + app.use("/", staticPages); + app.use("/", frontend); + app.use("/", activitypub); + app.use("/", event); + app.use("/", group); + app.use("/", magicLink); + app.use("/", routes); +} + +initializeApp().catch(console.error); export default app; diff --git a/src/helpers.ts b/src/helpers.ts index 47b380f..5590912 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,5 +1,8 @@ -import moment from "moment-timezone"; -import icalGenerator from "ical-generator"; +import mongoose from 'mongoose'; +import moment from 'moment-timezone'; +import icalGenerator from 'ical-generator'; +import i18next from 'i18next'; +import handlebars from 'handlebars'; import Log from "./models/Log.js"; import { getConfig } from "./lib/config.js"; import { IEvent } from "./models/Event.js"; @@ -10,41 +13,61 @@ const siteName = config.general.site_name; // LOGGING export function addToLog(process: string, status: string, message: string) { - const logEntry = { - status, - process, - message, - timestamp: new Date(), - }; - new Log(logEntry).save().catch(() => { - console.log("Error saving log entry!"); - }); + const logEntry = { + status, + process, + message, + timestamp: new Date(), + }; + new Log(logEntry).save().catch(() => { + console.log("Error saving log entry!"); + }); } -export function exportICal(events: IEvent[], calendarName: string) { - if (!events || events.length < 1) return; +export function exportIcal(events: IEvent | IEvent[], calendarName?: string) { // Ical -> ICal + // Create a new icalGenerator... generator + const cal = icalGenerator({ + name: calendarName || siteName, + timezone: 'UTC' + }); - // Create a new icalGenerator... generator - const cal = icalGenerator({ - name: calendarName || siteName, - }); - events.forEach((event) => { - // Add the event to the generator - cal.createEvent({ - start: moment.tz(event.start, event.timezone), - end: moment.tz(event.end, event.timezone), - timezone: event.timezone, - summary: event.name, - description: event.description, - organizer: { - name: event.hostName || "Anonymous", - email: event.creatorEmail || "anonymous@anonymous.com", - }, - location: event.location, - url: "https://" + domain + "/" + event.id, - }); + const eventArray = Array.isArray(events) ? events : [events]; + eventArray.forEach(event => { + cal.createEvent({ + start: moment.tz(event.start, event.timezone), + end: moment.tz(event.end, event.timezone), + timezone: event.timezone, + summary: event.name, + description: event.description, + organizer: { + name: event.hostName || "Anonymous", + email: event.creatorEmail || 'anonymous@anonymous.com', + }, + location: event.location, + url: 'https://' + domain + '/' + event.id }); - // Stringify it! - const string = cal.toString(); - return string; + }); + + return cal.toString(); +} + +interface I18nHelpers { + t: (key: string, options?: object) => string; + tn: (key: string, options?: object) => string; + count?: number; +} + +export function getI18nHelpers(): I18nHelpers { + return { + t: function(key: string, options?: object) { + const translation = i18next.t(key, { ...this, ...options }); + const template = handlebars.compile(translation); + return template(this); + }, + tn: function(key: string, options?: object) { + const translation = i18next.t(key, { count: this.count, ...options }); + const template = handlebars.compile(translation); + return template(this); + } + }; } diff --git a/src/lib/config.ts b/src/lib/config.ts index 6642eef..35fc42c 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -3,6 +3,7 @@ import toml from "toml"; import { exitWithError } from "./process.js"; import { Response } from "express"; import { markdownToSanitizedHTML } from "../util/markdown.js"; +import i18next from "i18next"; interface StaticPage { title: string; @@ -21,7 +22,7 @@ export interface GathioConfig { email_logo_url: string; show_kofi: boolean; show_public_event_list: boolean; - mail_service: "nodemailer" | "sendgrid" | "none"; + mail_service: "nodemailer" | "sendgrid" | "mailgun" | "none"; creator_email_addresses: string[]; }; database: { @@ -37,6 +38,11 @@ export interface GathioConfig { sendgrid?: { api_key: string; }; + mailgun?: { + api_key: string; + api_url: string; + domain: string; + }; static_pages?: StaticPage[]; } @@ -110,44 +116,44 @@ export const instanceRules = (): InstanceRule[] => { rules.push( config.general.show_public_event_list ? { - text: "Public events and groups are displayed on the homepage", + text: i18next.t("config.instancerule.showpubliceventlist-true"), icon: "fas fa-eye", } : { - text: "Events and groups can only be accessed by direct link", + text: i18next.t("config.instancerule.showpubliceventlist-false"), icon: "fas fa-eye-slash", }, ); rules.push( config.general.creator_email_addresses?.length ? { - text: "Only specific people can create events and groups", + text: i18next.t("config.instancerule.creatoremail-true"), icon: "fas fa-user-check", } : { - text: "Anyone can create events and groups", + text: i18next.t("config.instancerule.creatoremail-false"), icon: "fas fa-users", }, ); rules.push( config.general.delete_after_days > 0 ? { - text: `Events are automatically deleted ${config.general.delete_after_days} days after they end`, + text: i18next.t("config.instancerule.deleteafterdays-true", { days: config.general.delete_after_days } ), icon: "far fa-calendar-times", } : { - text: "Events are permanent, and are never automatically deleted", + text: i18next.t("config.instancerule.deleteafterdays-false"), icon: "far fa-calendar-check", }, ); rules.push( config.general.is_federated ? { - text: "This instance federates with other instances using ActivityPub", + text: i18next.t("config.instancerule.isfederated-true"), icon: "fas fa-globe", } : { - text: "This instance does not federate with other instances", + text: i18next.t("config.instancerule.isfederated-false"), icon: "fas fa-globe", }, ); @@ -156,13 +162,15 @@ export const instanceRules = (): InstanceRule[] => { export const instanceDescription = (): string => { const config = getConfig(); - const defaultInstanceDescription = - "**{{ siteName }}** is running on Gathio — a simple, federated, privacy-first event hosting platform."; + const defaultInstanceDescription = markdownToSanitizedHTML( + i18next.t("config.defaultinstancedesc", "Welcome to this Gathio instance!") + ); let instanceDescription = defaultInstanceDescription; + let instancedescfile = "./static/instance-description-" + i18next.language + ".md"; try { - if (fs.existsSync("./static/instance-description.md")) { + if (fs.existsSync(instancedescfile)) { const fileBody = fs.readFileSync( - "./static/instance-description.md", + instancedescfile, "utf-8", ); instanceDescription = markdownToSanitizedHTML(fileBody); diff --git a/src/lib/email.ts b/src/lib/email.ts index 7c7c2dd..fc585a1 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -5,6 +5,8 @@ import nodemailer, { Transporter } from "nodemailer"; import { GathioConfig, getConfig } from "./config.js"; import SMTPTransport from "nodemailer/lib/smtp-transport/index.js"; import { exitWithError } from "./process.js"; +import Mailgun from "mailgun.js"; +import { IMailgunClient } from "node_modules/mailgun.js/Types/Interfaces/index.js"; const config = getConfig(); @@ -24,7 +26,8 @@ type EmailTemplateName = export class EmailService { nodemailerTransporter: Transporter | undefined = undefined; sgMail: typeof sgMail | undefined = undefined; - hbs: ExpressHandlebars + mailgunClient: IMailgunClient | undefined = undefined; + hbs: ExpressHandlebars; public constructor(config: GathioConfig, hbs: ExpressHandlebars) { this.hbs = hbs; @@ -40,6 +43,26 @@ export class EmailService { console.log("Sendgrid is ready to send emails."); break; } + case "mailgun": { + if ( + !config.mailgun?.api_key || + !config.mailgun?.api_url || + !config.mailgun?.domain + ) { + return exitWithError( + "Mailgun is configured as the email service, but not all required fields are provided. Please provide all required fields in the config file.", + ); + } + const mailgun = new Mailgun(FormData); + this.mailgunClient = mailgun.client({ + username: "api", + key: config.mailgun.api_key, + url: config.mailgun.api_url, + }); + // TODO: Can we verify the Mailgun connection? + console.log("Mailgun is ready to send emails."); + break; + } case "nodemailer": { if (config.nodemailer?.smtp_url) { this.nodemailerTransporter = nodemailer.createTransport( @@ -73,13 +96,13 @@ export class EmailService { nodemailer.createTransport(nodemailerConfig); } } - } } public async verify(): Promise<boolean> { if (this.nodemailerTransporter) { - const nodemailerVerified = await this.nodemailerTransporter.verify(); + const nodemailerVerified = + await this.nodemailerTransporter.verify(); if (nodemailerVerified) { console.log("Nodemailer is ready to send emails."); return true; @@ -118,9 +141,36 @@ export class EmailService { return true; } catch (e: unknown | sgHelpers.classes.ResponseError) { if (e instanceof sgHelpers.classes.ResponseError) { - console.error('sendgrid error', e.response.body); + console.error("sendgrid error", e.response.body); + } else { + console.error("sendgrid error", e); + } + return false; + } + } else if (this.mailgunClient) { + try { + if (!config.mailgun?.domain) { + return exitWithError( + "Mailgun is configured as the email service, but no domain is provided. Please provide a domain in the config file.", + ); + } + await this.mailgunClient.messages.create( + config.mailgun.domain, + { + from: config.general.email, + to, + bcc, + subject: `${config.general.site_name}: ${subject}`, + text, + html, + }, + ); + return true; + } catch (e: any) { + if (e.response) { + console.error(e.response.body); } else { - console.error('sendgrid error', e); + console.error(e); } return false; } @@ -150,15 +200,14 @@ export class EmailService { bcc = "", subject, templateName, - templateData = {} + templateData = {}, }: { to: string | string[]; bcc?: string | string[] | undefined; subject: string; templateName: EmailTemplateName; templateData?: object; - }, - ): Promise<boolean> { + }): Promise<boolean> { const [html, text] = await Promise.all([ this.hbs.renderView( `./views/emails/${templateName}/${templateName}Html.handlebars`, @@ -172,7 +221,7 @@ export class EmailService { cache: true, layout: "email.handlebars", ...templateData, - } + }, ), this.hbs.renderView( `./views/emails/${templateName}/${templateName}Text.handlebars`, @@ -186,7 +235,7 @@ export class EmailService { cache: true, layout: "email.handlebars", ...templateData, - } + }, ), ]); @@ -195,7 +244,7 @@ export class EmailService { bcc, subject: `${config.general.site_name}: ${subject}`, text, - html + html, }); } } diff --git a/src/lib/event.ts b/src/lib/event.ts index 334ddf6..bcb7cd9 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -1,3 +1,4 @@ +import i18next from "i18next"; import { IEventGroup } from "../models/EventGroup.js"; export interface EventListEvent { @@ -15,7 +16,8 @@ export const bucketEventsByMonth = ( acc: Record<string, any>[], event: EventListEvent, ) => { - const month = event.startMoment.format("MMMM YYYY"); + event.startMoment.locale(i18next.language); + const month = event.startMoment.format(i18next.t("common.year-month-format" )); const matchingBucket = acc.find((bucket) => bucket.title === month); if (!matchingBucket) { acc.push({ diff --git a/src/lib/middleware.ts b/src/lib/middleware.ts index 69fbe4e..be05c3f 100644 --- a/src/lib/middleware.ts +++ b/src/lib/middleware.ts @@ -62,7 +62,7 @@ export const getConfigMiddleware = ( if (process.env.CYPRESS === "true" && req.cookies?.cypressConfigOverride) { console.log("Overriding config with Cypress config"); const override = JSON.parse(req.cookies.cypressConfigOverride); - res.locals.config = deepMerge<GathioConfig>(config, override); + res.locals.config = deepMerge(config, override); return next(); } res.locals.config = config; diff --git a/src/routes.js b/src/routes.js index e594890..6659670 100755 --- a/src/routes.js +++ b/src/routes.js @@ -22,6 +22,7 @@ import EventGroup from "./models/EventGroup.js"; import path from "path"; import { activityPubContentType } from "./lib/activitypub.js"; import { hashString } from "./util/generator.js"; +import i18next from "i18next"; import { EmailService } from "./lib/email.js"; const config = getConfig(); @@ -318,7 +319,7 @@ router.post("/deleteevent/:eventID/:editToken", (req, res) => { ); req.emailService.sendEmailFromTemplate({ to: attendeeEmails, - subject: `${event?.name} was deleted`, + subject: i18next.t("routes.deleteeventsubject", {eventName: event?.name}), templateName: "deleteEvent", templateData: { eventName: event?.name, @@ -631,7 +632,7 @@ router.post("/attendevent/:eventID", async (req, res) => { if (req.body.attendeeEmail) { req.emailService.sendEmailFromTemplate({ to: req.body.attendeeEmail, - subject: `You're RSVPed to ${event.name}`, + subject: i18next.t("routes.addeventattendeesubject", {eventName: event?.name}), templateName: "addEventAttendee", templateData:{ eventID: req.params.eventID, @@ -689,7 +690,7 @@ router.get("/oneclickunattendevent/:eventID/:attendeeID", (req, res) => { if (req.body.attendeeEmail) { req.emailService.sendEmailFromTemplate({ to: req.body.attendeeEmail, - subject: `You have been removed from an event`, + subject: i18next.t("routes.removeeventattendeesubject"), templateName: "removeEventAttendee", templateData:{ eventName: event.name, @@ -735,7 +736,7 @@ router.post("/removeattendee/:eventID/:attendeeID", (req, res) => { if (req.body.attendeeEmail) { req.emailService.sendEmailFromTemplate({ to: req.body.attendeeEmail, - subject: `You have been removed from an event`, + subject: i18next.t("routes.removeeventattendeesubject"), templateName: "removeEventAttendee", templateData: { eventName: event.name, @@ -785,7 +786,7 @@ router.post("/subscribe/:eventGroupID", (req, res) => { eventGroup.save(); req.emailService.sendEmailFromTemplate({ to: subscriber.email, - subject: "You have subscribed to an event group", + subject: i18next.t("routes.subscribedsubject"), templateName: "subscribed", templateData:{ eventGroupName: eventGroup.name, @@ -906,7 +907,7 @@ router.post("/post/comment/:eventID", (req, res) => { req.emailService.sendEmailFromTemplate({ to: event?.creatorEmail || config.general.email, bcc: attendeeEmails, - subject: `New comment in ${event.name}`, + subject: i18next.t("routes.addeventcommentsubject", { eventName: event?.name }), templateName: "addEventComment", templateData:{ eventID: req.params.eventID, @@ -1004,7 +1005,7 @@ router.post("/post/reply/:eventID/:commentID", (req, res) => { req.emailService.sendEmailFromTemplate({ to: event?.creatorEmail || config.general.email, bcc: attendeeEmails, - subject: `New comment in ${event.name}`, + subject: i18next.t("routes.addeventcommentsubject", { eventName: event.name }), templateName: "addEventComment", templateData: { eventID: req.params.eventID, diff --git a/src/routes/event.ts b/src/routes/event.ts index ee45d96..84a7c6b 100644 --- a/src/routes/event.ts +++ b/src/routes/event.ts @@ -27,7 +27,8 @@ import ical from "ical"; import { markdownToSanitizedHTML } from "../util/markdown.js"; import { checkMagicLink, getConfigMiddleware } from "../lib/middleware.js"; import { getConfig } from "../lib/config.js"; - +import i18next from "i18next"; +moment.locale(i18next.language); const config = getConfig(); const storage = multer.memoryStorage(); @@ -397,33 +398,33 @@ router.put( : undefined, }; let diffText = - "<p>This event was just updated with new information.</p><ul>"; + "<p>" + i18next.t("routes.event.difftext") + "</p><ul>"; let displayDate; if (event.name !== updatedEvent.name) { - diffText += `<li>the event name changed to ${updatedEvent.name}</li>`; + diffText += `<li>` + i18next.t("routes.event.namechanged", { eventname: updatedEvent.name} ) + `</li>`; } if (event.location !== updatedEvent.location) { - diffText += `<li>the location changed to ${updatedEvent.location}</li>`; + diffText += `<li>` + i18next.t("routes.event.locationchanged", { location: updatedEvent.location} ) + `</li>`; } if ( event.start.toISOString() !== updatedEvent.start.toISOString() ) { displayDate = moment .tz(updatedEvent.start, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `<li>the start time changed to ${displayDate}</li>`; + .format(i18next.t("common.datetimeformat")); + diffText += `<li>` + i18next.t("routes.event.starttimechanged", { starttime: displayDate }) + `</li>`; } if (event.end.toISOString() !== updatedEvent.end.toISOString()) { displayDate = moment .tz(updatedEvent.end, updatedEvent.timezone) - .format("dddd D MMMM YYYY h:mm a"); - diffText += `<li>the end time changed to ${displayDate}</li>`; + .format(i18next.t("common.datetimeformat")); + diffText += `<li>` + i18next.t("routes.event.endtimechanged", { endtime: displayDate }) + `</li>`; } if (event.timezone !== updatedEvent.timezone) { - diffText += `<li>the time zone changed to ${updatedEvent.timezone}</li>`; + diffText += `<li>` + i18next.t("routes.event.timezonechanged", { timezone: updatedEvent.timezone }) + `</li>`; } if (event.description !== updatedEvent.description) { - diffText += `<li>the event description changed</li>`; + diffText += `<li>` + i18next.t("routes.event.descriptionchanged") + `</li>`; } diffText += `</ul>`; const updatedEventObject = await Event.findOneAndUpdate( @@ -487,7 +488,7 @@ router.put( req.emailService.sendEmailFromTemplate({ to: config.general.email, bcc: attendeeEmails, - subject: `${event.name} was just edited`, + subject: i18next.t("routes.event.editedsubject", { eventname: event.name}), templateName: "editEvent", templateData: { diffText, @@ -672,7 +673,7 @@ router.delete( if (attendeeEmail) { await req.emailService.sendEmailFromTemplate({ to: attendeeEmail, - subject: "You have been removed from an event", + subject: i18next.t("routes.removeeventattendeesubject"), templateName: "unattendEvent", templateData: { eventID: req.params.eventID, diff --git a/src/routes/frontend.ts b/src/routes/frontend.ts index 1b95763..fca14c6 100644 --- a/src/routes/frontend.ts +++ b/src/routes/frontend.ts @@ -7,7 +7,7 @@ import { instanceDescription, instanceRules, } from "../lib/config.js"; -import { addToLog, exportICal } from "../helpers.js"; +import { addToLog, exportIcal } from "../helpers.js"; import Event from "../models/Event.js"; import EventGroup, { IEventGroup } from "../models/EventGroup.js"; import { @@ -18,6 +18,7 @@ import MagicLink from "../models/MagicLink.js"; import { getConfigMiddleware } from "../lib/middleware.js"; import { getMessage } from "../util/messages.js"; import { EventListEvent, bucketEventsByMonth } from "../lib/event.js"; +import i18next from "i18next"; const router = Router(); @@ -48,7 +49,7 @@ router.get("/new", (_: Request, res: Response) => { return res.render("createEventMagicLink", frontendConfig(res)); } return res.render("newevent", { - title: "New event", + title: i18next.t("frontend.newevent"), ...frontendConfig(res), }); }); @@ -69,12 +70,12 @@ router.get("/new/:magicLinkToken", async (req: Request, res: Response) => { ...frontendConfig(res), message: { type: "danger", - text: "This magic link is invalid or has expired. Please request a new one here.", + text: i18next.t("routes.magiclink-invalid"), }, }); } res.render("newevent", { - title: "New event", + title: i18next.t("frontend.newevent"), ...frontendConfig(res), magicLinkToken: req.params.magicLinkToken, creatorEmail: magicLink.email, @@ -99,9 +100,9 @@ router.get("/events", async (_: Request, res: Response) => { name: event.name, location: event.location, displayDate: isSameDay - ? startMoment.format("D MMM YYYY") - : `${startMoment.format("D MMM YYYY")} - ${endMoment.format( - "D MMM YYYY", + ? startMoment.format("LL") + : `${startMoment.format("LL")} - ${endMoment.format( + "LL", )}`, eventHasConcluded: endMoment.isBefore(moment.tz(event.timezone)), eventGroup: event.eventGroup as any as IEventGroup, @@ -131,7 +132,7 @@ router.get("/events", async (_: Request, res: Response) => { }); res.render("publicEventList", { - title: "Public events", + title: i18next.t("frontend.publicevents"), upcomingEvents: upcomingEventsInMonthBuckets, pastEvents: pastEventsInMonthBuckets, eventGroups: updatedEventGroups, @@ -153,31 +154,53 @@ router.get("/:eventID", async (req: Request, res: Response) => { } const parsedLocation = event.location.replace(/\s+/g, "+"); let displayDate; + const dateformat = i18next.t("frontend.dateformat"); + const timeformat = i18next.t('frontend.timeformat'); if (moment.tz(event.end, event.timezone).isSame(event.start, "day")) { // Happening during one day - displayDate = - moment - .tz(event.start, event.timezone) - .format( - 'dddd D MMMM YYYY [<span class="text-muted">from</span>] h:mm a', - ) + - moment - .tz(event.end, event.timezone) - .format( - ' [<span class="text-muted">to</span>] h:mm a [<span class="text-muted">](z)[</span>]', - ); + displayDate = i18next.t("frontend.displaydate-sameday", + { + startdate: + moment + .tz(event.start, event.timezone) + .format(dateformat), + starttime: + moment + .tz(event.start, event.timezone) + .format(timeformat), + endtime: + moment + .tz(event.end, event.timezone) + .format(timeformat), + timezone: + moment + .tz(event.end, event.timezone) + .format('(z)',) + }); } else { - displayDate = - moment - .tz(event.start, event.timezone) - .format( - 'dddd D MMMM YYYY [<span class="text-muted">at</span>] h:mm a', - ) + - moment - .tz(event.end, event.timezone) - .format( - ' [<span class="text-muted">–</span>] dddd D MMMM YYYY [<span class="text-muted">at</span>] h:mm a [<span class="text-muted">](z)[</span>]', - ); + displayDate = i18next.t("frontend.displaydate-days", + { + startdate: + moment + .tz(event.start, event.timezone) + .format(dateformat), + starttime: + moment + .tz(event.start, event.timezone) + .format(timeformat), + enddate: + moment + .tz(event.end, event.timezone) + .format(dateformat), + endtime: + moment + .tz(event.end, event.timezone) + .format(timeformat), + timezone: + moment + .tz(event.end, event.timezone) + .format('(z)',) + }); } let eventStartISO = moment.tz(event.start, "Etc/UTC").toISOString(); let eventEndISO = moment.tz(event.end, "Etc/UTC").toISOString(); @@ -256,7 +279,7 @@ router.get("/:eventID", async (req: Request, res: Response) => { el.id = el._id; } if (el.number && el.number > 1) { - el.name = `${el.name} (${el.number} people)`; + el.name = `${el.name} ${i18next.t("frontend.elnumber", { count: el.number })}`; } return { ...el, @@ -428,8 +451,8 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { .sort("start"); const updatedEvents: EventListEvent[] = events.map((event) => { - const startMoment = moment.tz(event.start, event.timezone); - const endMoment = moment.tz(event.end, event.timezone); + const startMoment = moment.tz(event.start, event.timezone).locale(i18next.language); + const endMoment = moment.tz(event.end, event.timezone).locale(i18next.language); const isSameDay = startMoment.isSame(endMoment, "day"); return { @@ -437,10 +460,8 @@ router.get("/group/:eventGroupID", async (req: Request, res: Response) => { name: event.name, location: event.location, displayDate: isSameDay - ? startMoment.format("D MMM YYYY") - : `${startMoment.format("D MMM YYYY")} - ${endMoment.format( - "D MMM YYYY", - )}`, + ? startMoment.format("LL") + : `${startMoment.format("LL")} - ${endMoment.format("LL")}`, eventHasConcluded: endMoment.isBefore( moment.tz(event.timezone), ), @@ -545,7 +566,7 @@ router.get( const events = await Event.find({ eventGroup: eventGroup._id, }).sort("start"); - const string = exportICal(events, eventGroup.name); + const string = exportIcal(events, eventGroup.name); res.set("Content-Type", "text/calendar").send(string); } } catch (err) { @@ -567,7 +588,7 @@ router.get("/export/event/:eventID", async (req: Request, res: Response) => { }).populate("eventGroup"); if (event) { - const string = exportICal([event], event.name); + const string = exportIcal([event], event.name); res.set("Content-Type", "text/calendar").send(string); } } catch (err) { @@ -593,7 +614,7 @@ router.get( const events = await Event.find({ eventGroup: eventGroup._id, }).sort("start"); - const string = exportICal(events, eventGroup.name); + const string = exportIcal(events, eventGroup.name); res.set("Content-Type", "text/calendar").send(string); } } catch (err) { diff --git a/src/routes/magicLink.ts b/src/routes/magicLink.ts index e0a6310..1e0f87b 100644 --- a/src/routes/magicLink.ts +++ b/src/routes/magicLink.ts @@ -3,6 +3,7 @@ import { frontendConfig } from "../lib/config.js"; import { generateMagicLinkToken } from "../util/generator.js"; import MagicLink from "../models/MagicLink.js"; import { getConfigMiddleware } from "../lib/middleware.js"; +import i18next from "i18next"; const router = Router(); @@ -15,7 +16,7 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => { ...frontendConfig(res), message: { type: "danger", - text: "Please provide an email address.", + text: i18next.t("routes.magiclink.provideemail"), }, }); return; @@ -30,7 +31,7 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => { ...frontendConfig(res), message: { type: "success", - text: "Thanks! If this email address can create events, you should receive an email with a magic link.", + text: i18next.t("routes.magiclink.thanks"), }, }); return; @@ -49,7 +50,7 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => { req.emailService.sendEmailFromTemplate({ to: email, - subject: "Magic link to create an event", + subject: i18next.t("routes.magiclink.mailsubject"), templateName: "createEventMagicLink", templateData: { token @@ -59,7 +60,7 @@ router.post("/magic-link/event/create", async (req: Request, res: Response) => { ...frontendConfig(res), message: { type: "success", - text: "Thanks! If this email address can create events, you should receive an email with a magic link.", + text: i18next.t("routes.magiclink.thanks"), }, }); }); diff --git a/src/types/i18next-fs-backend.d.ts b/src/types/i18next-fs-backend.d.ts new file mode 100644 index 0000000..33714e7 --- /dev/null +++ b/src/types/i18next-fs-backend.d.ts @@ -0,0 +1,5 @@ +declare module 'i18next-fs-backend' { + import { BackendModule } from 'i18next'; + const backend: BackendModule; + export default backend; +}
\ No newline at end of file diff --git a/src/util/validation.ts b/src/util/validation.ts index a3bea63..42b524a 100644 --- a/src/util/validation.ts +++ b/src/util/validation.ts @@ -1,3 +1,4 @@ +import i18next from "i18next"; import moment from "moment-timezone"; type Error = { @@ -90,26 +91,26 @@ const validateUrl = (url: string) => { export const validateEventTime = (start: Date, end: Date): Error | boolean => { if (moment(start).isAfter(moment(end))) { return { - message: "Start time must be before end time.", + message: i18next.t('util.validation.eventtime.startisafter'), field: "eventStart", }; } if (moment(start).isBefore(moment())) { return { - message: "Start time must be in the future.", + message: i18next.t('util.validation.eventtime.startisbefore'), field: "eventStart", }; } if (moment(end).isBefore(moment())) { return { - message: "End time must be in the future.", + message: i18next.t('util.validation.eventtime.endisbefore'), field: "eventEnd", }; } // Duration cannot be longer than 1 year if (moment(end).diff(moment(start), "years") > 1) { return { - message: "Event duration cannot be longer than 1 year.", + message: i18next.t("util.validation.eventtime.endyears"), field: "eventEnd", }; } @@ -130,25 +131,25 @@ export const validateEventData = ( const errors: Error[] = []; if (!validatedData.eventName) { errors.push({ - message: "Event name is required.", + message: i18next.t('util.validation.eventdata.eventname'), field: "eventName", }); } if (!validatedData.eventLocation) { errors.push({ - message: "Event location is required.", + message: i18next.t("util.validation.eventdata.eventlocation"), field: "eventLocation", }); } if (!validatedData.eventStart) { errors.push({ - message: "Event start time is required.", + message: i18next.t("util.validation.eventdata.eventstart"), field: "eventStart", }); } if (!validatedData.eventEnd) { errors.push({ - message: "Event end time is required.", + message: i18next.t("util.validation.eventdata.eventend"), field: "eventEnd", }); } @@ -163,26 +164,26 @@ export const validateEventData = ( } if (!validatedData.timezone) { errors.push({ - message: "Event timezone is required.", + message: i18next.t("util.validation.eventdata.timezone"), field: "timezone", }); } if (!validatedData.eventDescription) { errors.push({ - message: "Event description is required.", + message: i18next.t("util.validation.eventdata.eventdescription"), field: "eventDescription", }); } if (validatedData.eventGroupBoolean) { if (!validatedData.eventGroupID) { errors.push({ - message: "Event group ID is required.", + message: i18next.t("util.validation.eventdata.eventgroupboolean"), field: "eventGroupID", }); } if (!validatedData.eventGroupEditToken) { errors.push({ - message: "Event group edit token is required.", + message: i18next.t("util.validation.eventdata.eventgroupedittoken"), field: "eventGroupEditToken", }); } @@ -190,13 +191,13 @@ export const validateEventData = ( if (validatedData.maxAttendeesBoolean) { if (!validatedData.maxAttendees) { errors.push({ - message: "Max number of attendees is required.", + message: i18next.t("util.validation.eventdata.maxattendeesboolean"), field: "maxAttendees", }); } if (isNaN(validatedData.maxAttendees)) { errors.push({ - message: "Max number of attendees must be a number.", + message: i18next.t("util.validation.eventdata.maxattendees"), field: "maxAttendees", }); } @@ -204,7 +205,7 @@ export const validateEventData = ( if (validatedData.creatorEmail) { if (!validateEmail(validatedData.creatorEmail)) { errors.push({ - message: "Email address is invalid.", + message: i18next.t("util.validation.eventdata.creatoremail"), field: "creatorEmail", }); } @@ -212,7 +213,7 @@ export const validateEventData = ( if (validatedData.eventURL) { if (!validateUrl(validatedData.eventURL)) { errors.push({ - message: "Event link is invalid.", + message: i18next.t("util.validation.eventdata.eventurl"), field: "eventURL", }); } @@ -230,20 +231,20 @@ export const validateGroupData = ( const errors: Error[] = []; if (!groupData.eventGroupName) { errors.push({ - message: "Event group name is required.", + message: i18next.t("util.validation.groupdata.eventgroupname"), field: "eventGroupName", }); } if (!groupData.eventGroupDescription) { errors.push({ - message: "Event group description is required.", + message: i18next.t("util.validation.groupdata.eventgroupdescription"), field: "eventGroupDescription", }); } if (groupData.creatorEmail) { if (!validateEmail(groupData.creatorEmail)) { errors.push({ - message: "Email address is invalid.", + message: i18next.t("util.validation.groupdata.creatoremail"), field: "creatorEmail", }); } @@ -251,7 +252,7 @@ export const validateGroupData = ( if (groupData.eventGroupURL) { if (!validateUrl(groupData.eventGroupURL)) { errors.push({ - message: "Group link is invalid.", + message: i18next.t("util.validation.groupdata.eventgroupurl"), field: "eventGroupURL", }); } |