From 4b8c8be226bdfee3b98b5eb9335d0b6297436401 Mon Sep 17 00:00:00 2001 From: Marko Korhonen Date: Thu, 23 Nov 2023 17:24:08 +0200 Subject: [PATCH] Add i18n support and translations for finnish language --- config.toml | 10 ++++ src/config.ts | 28 +++------- src/format.ts | 46 ++++++++++------ src/i18n.ts | 120 +++++++++++++++++++++++++++++++++++++++++ src/input.ts | 52 +++++++----------- src/output.ts | 35 +++++++----- src/types/Language.ts | 6 +++ src/types/WtcConfig.ts | 20 +++++++ src/ui.ts | 6 ++- 9 files changed, 239 insertions(+), 84 deletions(-) create mode 100644 src/i18n.ts create mode 100644 src/types/Language.ts create mode 100644 src/types/WtcConfig.ts diff --git a/config.toml b/config.toml index 976c159..895729b 100644 --- a/config.toml +++ b/config.toml @@ -5,15 +5,24 @@ # You can place your configuration file in $XDG_CONFIG_HOME/wtc/config.toml, # usually ~/.config/wtc/config.toml +# The language of the application. +# Currently supported languages are "en", "fi" +language = "en" + # This section is for default values for inputs [defaults] + # Leave empty if you don't have an unpaid lunch break # or if you normally log your lunch break hours lunchBreakDuration = "00:30" + # Your work day duration workDayDuration = "07:30" + # The time you start working + startTime = "08:00" + # The time you stop working. Can either be "now" or a time stopTime = "now" @@ -25,5 +34,6 @@ workDayDuration = true startTime = true stopTime = true logged = true + # It is assumed that you didn't have lunch if this is false hadLunch = true diff --git a/src/config.ts b/src/config.ts index d7192a3..8049b22 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,27 +2,11 @@ import fs from 'fs'; import path from 'path'; import { xdgConfig } from 'xdg-basedir'; import toml from '@iarna/toml'; -import { Dayjs } from 'dayjs'; -import { Duration } from 'dayjs/plugin/duration.js'; import { parseDuration, parseTimestamp } from './parse.js'; +import WtcConfig from './types/WtcConfig.js'; +import Language from './types/Language.js'; -interface Config { - defaults: { - workDayDuration: Duration; - lunchBreakDuration: Duration; - startTime: Dayjs; - stopTime: Dayjs; - }; - askInput: { - workDayLength: boolean; - startTime: boolean; - stopTime: boolean; - logged: boolean; - hadLunch: boolean; - }; -} - -interface RawConfig extends Omit { +interface RawConfig extends Omit { defaults: { workDayDuration: string; lunchBreakDuration: string; @@ -32,6 +16,7 @@ interface RawConfig extends Omit { } const defaultConfig: RawConfig = { + language: Language.en, defaults: { workDayDuration: '07:30', lunchBreakDuration: '00:30', @@ -47,9 +32,9 @@ const defaultConfig: RawConfig = { }, }; -const getConfig = (): Config => { +const getConfig = (): WtcConfig => { const configDir = xdgConfig || path.join(process.env.HOME ?? './', '.config'); - let configFilePath = path.join(configDir, 'wct', 'config.toml'); + let configFilePath = path.join(configDir, 'wtc', 'config.toml'); let configData: RawConfig; if (fs.existsSync(configFilePath)) { @@ -59,6 +44,7 @@ const getConfig = (): Config => { } return { + language: configData.language ?? defaultConfig.language, defaults: { workDayDuration: parseDuration( configData.defaults.workDayDuration ?? defaultConfig.defaults.workDayDuration, diff --git a/src/format.ts b/src/format.ts index 3be9bb4..c831e24 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,28 +1,44 @@ import dayjs, { Dayjs } from 'dayjs'; import { Duration } from 'dayjs/plugin/duration.js'; +import Language from './types/Language'; +import { MessageKey, message } from './i18n'; export const formatTimestamp = (timestamp: Dayjs): string => timestamp.format('YYYY-MM-DD HH:mm'); export const formatTime = (time: Dayjs): string => time.format('HH:mm'); -export const formatDuration = (duration: Duration, short?: boolean): string => { - if (duration.hours() === 0 && duration.minutes() === 0) { - return 'none'; - } +export const formatDuration = + (language: Language) => + (duration: Duration, short?: boolean): string => { + if (duration.hours() === 0 && duration.minutes() === 0) { + return 'none'; + } - const formatString = short - ? 'HH:mm' - : duration.hours() > 0 && duration.minutes() > 0 - ? `H [hour${duration.hours() > 1 ? 's' : ''} and] m [minute${duration.minutes() > 1 ? 's' : ''}]` - : duration.hours() > 0 - ? `H [hour${duration.hours() > 1 ? 's' : ''}]` - : `m [minute${duration.minutes() > 1 ? 's' : ''}]`; + let formatString; - return duration.format(formatString); -}; + if (short) { + formatString = 'HH:mm'; + } else if (language === Language.fi) { + formatString = + duration.hours() > 0 && duration.minutes() > 0 + ? `H [tunti${duration.hours() > 1 ? 'a' : ''} ja] m [minuutti${duration.minutes() > 1 ? 'a' : ''}]` + : duration.hours() > 0 + ? `H [tunti${duration.hours() > 1 ? 'a' : ''}]` + : `m [minutti${duration.minutes() > 1 ? 'a' : ''}]`; + } else { + formatString = + duration.hours() > 0 && duration.minutes() > 0 + ? `H [hour${duration.hours() > 1 ? 's' : ''} and] m [minute${duration.minutes() > 1 ? 's' : ''}]` + : duration.hours() > 0 + ? `H [hour${duration.hours() > 1 ? 's' : ''}]` + : `m [minute${duration.minutes() > 1 ? 's' : ''}]`; + } -export const getHoursRoundedStr = (duration: Duration) => - `(${getHoursRounded(duration)} as hours rounded to next even 15 minutes)`; + return duration.format(formatString); + }; + +export const getHoursRoundedStr = (language: Language) => (duration: Duration) => + `(${getHoursRounded(duration)} ${message(language)(MessageKey.hoursRounded)})`; const getHoursRounded = (duration: Duration) => { // Round up to the next multiple of 15 diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..833a729 --- /dev/null +++ b/src/i18n.ts @@ -0,0 +1,120 @@ +import Language from './types/Language'; + +export enum MessageKey { + promptWorkDayDuration, + promptStartTime, + promptStopTime, + parseTimeFailed, + startTimeBeforeStopTimeError, + promptLunchBreak, + promptLogged, + none, + startedWorking, + stoppedWorking, + workedToday, + loggedOver, + workedOvertime, + workLeft, + hoursCalculated, + klo, + unloggedToday, + hoursRounded, +} + +const messages: Record> = { + [MessageKey.promptWorkDayDuration]: { + [Language.en]: 'How long is your work day today, excluding the lunch break? [{0}]: ', + [Language.fi]: 'Kuinka pitkä työpäiväsi on tänään, poisluettuna lounastauko? [{0}]: ', + }, + [MessageKey.promptStartTime]: { + [Language.en]: 'What time did you start work today? [{0}]: ', + [Language.fi]: 'Mihin aikaan aloitit työskentelyn tänään? [{0}]: ', + }, + [MessageKey.promptStopTime]: { + [Language.en]: "What time did you stop working? If you didn't stop yet, leave this empty: ", + [Language.fi]: 'Mihin aikaan lopetit työskentelyn? Jos et lopettanut vielä, jätä tämä tyhjäksi: ', + }, + [MessageKey.parseTimeFailed]: { + [Language.en]: 'Failed to parse time "{0}", using default value "{1}"', + [Language.fi]: 'Ajan "{0}" parsiminen epäonnistui, käytetään oletusasetusta "{1}"', + }, + [MessageKey.startTimeBeforeStopTimeError]: { + [Language.en]: 'Start time ({0}) needs to be before stop time ({1}). Exiting', + [Language.fi]: 'Aloitusaika ({0}) pitää olla ennen lopetusaikaa ({1}). Ohjelma sammuu', + }, + [MessageKey.promptLunchBreak]: { + [Language.en]: 'Did you have a lunch break? [y/N]: ', + [Language.fi]: 'Piditkö jo lounastauon? [k/E]: ', + }, + [MessageKey.promptLogged]: { + [Language.en]: 'How many hours did you log already? [00:00] ', + [Language.fi]: 'Kuinka monta tuntia kirjasit jo? [00:00] ', + }, + [MessageKey.none]: { + [Language.en]: 'None', + [Language.fi]: 'Ei yhtään', + }, + [MessageKey.startedWorking]: { + [Language.en]: 'Started working:', + [Language.fi]: 'Aloitit työskentelyn:', + }, + [MessageKey.stoppedWorking]: { + [Language.en]: 'Stopped working', + [Language.fi]: 'Lopetit työskentelyn', + }, + [MessageKey.hoursCalculated]: { + [Language.en]: 'Hours calculated', + [Language.fi]: 'Tunnit laskettu', + }, + [MessageKey.workedToday]: { + [Language.en]: 'Worked today:', + [Language.fi]: 'Tänään työskennelty:', + }, + [MessageKey.workedOvertime]: { + [Language.en]: 'You worked {0} overtime today', + [Language.fi]: 'Olet tehnyt {0} ylitöitä tänään', + }, + [MessageKey.loggedOver]: { + [Language.en]: 'You have logged {0} more than you worked today!', + [Language.fi]: 'Olet kirjannut {0} enemmän kuin olet työskennellyt!', + }, + [MessageKey.workLeft]: { + [Language.en]: 'You still have to work {0} more today', + [Language.fi]: 'Sinun pitää työskennellä tänään vielä {0} lisää', + }, + [MessageKey.klo]: { + [Language.en]: 'at', + [Language.fi]: 'klo', + }, + [MessageKey.unloggedToday]: { + [Language.en]: 'Unlogged today:', + [Language.fi]: 'Kirjaamattomia tänään:', + }, + [MessageKey.hoursRounded]: { + [Language.en]: 'as hours rounded to next even 15 minutes', + [Language.fi]: 'tunteina pyöristettynä seuraavaan 15 minuuttiin', + }, +}; + +/** + * Get a function to fetch messages for a given language + * @param language The language to get the messages for + */ +export const message = + (language: Language) => + /** + * Get a message for a fiven key + * @param key The key of the message + */ + (key: keyof typeof messages, ...params: string[]) => { + let result = messages[key][language]; + if (!result) { + throw `Unknown language: ${language}`; + } + + // Replace parameters in the template + for (let i = 0; i < params.length; i++) { + result = result.replace(new RegExp(`\\{${i}\\}`, 'g'), params[i]); + } + return result; + }; diff --git a/src/input.ts b/src/input.ts index 4b250f1..e49e582 100644 --- a/src/input.ts +++ b/src/input.ts @@ -7,13 +7,17 @@ import { formatDuration, formatTime } from './format'; import dayjs, { Dayjs } from 'dayjs'; import { WtcPromptResult } from './types/WtcPromptResult'; import duration from 'dayjs/plugin/duration.js'; +import WtcConfig from './types/WtcConfig'; +import { MessageKey, message } from './i18n'; dayjs.extend(duration); const { error } = console; -const input = async (): Promise => { - const { defaults, askInput } = getConfig(); +const input = async (config: WtcConfig): Promise => { + const msg = message(config.language); + const fmtDuration = formatDuration(config.language); + const { defaults, askInput } = config; const rl = readline.createInterface({ input: process.stdin, output: process.stdout, @@ -29,20 +33,14 @@ const input = async (): Promise => { if (askInput.workDayLength) { const durationAnswer = await rl.question( - `How long is your work day today, excluding the lunch break? [${formatDuration( - defaults.workDayDuration, - true, - )}] `, + msg(MessageKey.promptWorkDayDuration, fmtDuration(defaults.workDayDuration, true)), ); if (durationAnswer !== '') { workDayDuration = parseDuration(durationAnswer); if (workDayDuration.asMinutes() <= 0) { error( chalk.red( - `Failed to parse ${durationAnswer} to duration, using default work day duration ${formatDuration( - defaults.workDayDuration, - true, - )}`, + msg(MessageKey.parseTimeFailed, durationAnswer, fmtDuration(defaults.workDayDuration)), ), ); workDayDuration = undefined; @@ -55,19 +53,11 @@ const input = async (): Promise => { } if (askInput.startTime) { - const startTimeAnswer = await rl.question( - `What time did you start work today? [${formatTime(defaults.startTime)}] `, - ); + const startTimeAnswer = await rl.question(msg(MessageKey.promptStartTime, formatTime(defaults.startTime))); if (startTimeAnswer !== '') { startedAt = parseTimestamp(startTimeAnswer); if (!startedAt.isValid()) { - error( - chalk.red( - `Failed to parse ${startTimeAnswer} to time, using default start time ${formatTime( - defaults.startTime, - )}`, - ), - ); + error(chalk.red(msg(MessageKey.parseTimeFailed, startTimeAnswer, formatTime(defaults.startTime)))); } } } @@ -77,16 +67,13 @@ const input = async (): Promise => { } if (askInput.stopTime) { - const stoppedAnswer = await rl.question( - `What time did you stop working? [${formatTime(defaults.stopTime)}] `, - ); + const stoppedAnswer = await rl.question(msg(MessageKey.promptStopTime, formatTime(defaults.stopTime))); if (stoppedAnswer !== '') { stoppedWorking = true; stoppedAt = parseTimestamp(stoppedAnswer); if (!stoppedAt.isValid()) { - error(`Failed to parse ${stoppedAnswer} to time, using current time`); - stoppedAt = dayjs(); + error(chalk.red(msg(MessageKey.parseTimeFailed, stoppedAnswer, formatTime(defaults.stopTime)))); } } } @@ -97,26 +84,25 @@ const input = async (): Promise => { if (stoppedAt.isSame(startedAt) || stoppedAt.isBefore(startedAt)) { error( - chalk.red( - `Start time (${formatTime(startedAt)}) needs to be before stop time (${formatTime( - stoppedAt, - )}). Exiting`, - ), + chalk.red(msg(MessageKey.startTimeBeforeStopTimeError, formatTime(startedAt), formatTime(stoppedAt))), ); process.exit(1); } let worked = dayjs.duration(stoppedAt.diff(startedAt)); - const hadLunch = - askInput.hadLunch && (await rl.question('Did you have a lunch break? [Y/n] ')).toLowerCase() !== 'n'; + let hadLunch = false; + if (askInput.hadLunch) { + const lunchAnswer = (await rl.question(msg(MessageKey.promptLunchBreak))).toLowerCase(); + hadLunch = lunchAnswer === 'y' || lunchAnswer === 'k'; + } if (hadLunch) { worked = worked.subtract(defaults.lunchBreakDuration); } // Calculate unlogged time - let loggedAnswer = await rl.question('How many hours did you log already? [00:00] '); + let loggedAnswer = await rl.question(msg(MessageKey.promptLogged)); if (loggedAnswer === '') { loggedAnswer = '00:00'; } diff --git a/src/output.ts b/src/output.ts index a45fbdc..5e98780 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,31 +1,40 @@ import chalk from 'chalk'; import { formatDuration, formatTimestamp, getHoursRoundedStr } from './format'; import { WtcPromptResult } from './types/WtcPromptResult'; +import { MessageKey, message } from './i18n.js'; +import WtcConfig from './types/WtcConfig'; const { log } = console; -const output = (result: WtcPromptResult) => { +const output = (result: WtcPromptResult, config: WtcConfig) => { + const msg = message(config.language); + const fmtDuration = formatDuration(config.language); + const hoursRounded = getHoursRoundedStr(config.language); const { startedAt, stoppedAt, stoppedWorking, worked, unLogged, workLeft, workedOverTime } = result; log(); - log('Started working at:', formatTimestamp(startedAt)); - log((stoppedWorking ? 'Stopped working' : 'Hours calculated') + ' at:', formatTimestamp(stoppedAt)); - log('Worked today:', chalk.green(formatDuration(worked)), chalk.yellow(getHoursRoundedStr(worked))); + log(msg(MessageKey.startedWorking), formatTimestamp(startedAt)); + log( + (stoppedWorking ? msg(MessageKey.stoppedWorking) : msg(MessageKey.hoursCalculated)) + + ` ${msg(MessageKey.klo)}:`, + formatTimestamp(stoppedAt), + ); + log(msg(MessageKey.workedToday), chalk.green(fmtDuration(worked)), chalk.yellow(hoursRounded(worked))); - if (unLogged.asMinutes() == 0) { - log('Unlogged today:', chalk.green('none')); - } else if (unLogged.asMinutes() > 0) { - log('Unlogged today:', chalk.red(formatDuration(unLogged)), chalk.yellow(getHoursRoundedStr(unLogged))); - } else if (unLogged.asMinutes() < 0) { + const unLoggedMinutes = unLogged.asMinutes(); + if (unLoggedMinutes >= 0) { log( - chalk.red(`You have logged ${formatDuration(unLogged)} more than you worked today!`), - chalk.yellow(getHoursRoundedStr(unLogged)), + msg(MessageKey.unloggedToday), + unLoggedMinutes === 0 ? chalk.green(msg(MessageKey.none)) : chalk.red(fmtDuration(unLogged)), + chalk.yellow(hoursRounded(unLogged)), ); + } else if (unLoggedMinutes < 0) { + log(chalk.red(msg(MessageKey.loggedOver, fmtDuration(unLogged))), chalk.yellow(hoursRounded(unLogged))); } if (workLeft.asMinutes() > 0) { - log('You still have to work', chalk.green(formatDuration(workLeft)), 'more today'); + log(msg(MessageKey.workLeft, chalk.green(fmtDuration(workLeft)))); } else if (workedOverTime) { - log('You worked', chalk.green(formatDuration(workedOverTime), 'overtime today')); + log(msg(MessageKey.workedOvertime, chalk.green(fmtDuration(workedOverTime)))); } }; diff --git a/src/types/Language.ts b/src/types/Language.ts new file mode 100644 index 0000000..3dcd6d4 --- /dev/null +++ b/src/types/Language.ts @@ -0,0 +1,6 @@ +enum Language { + en = 'en', + fi = 'fi', +} + +export default Language; diff --git a/src/types/WtcConfig.ts b/src/types/WtcConfig.ts new file mode 100644 index 0000000..65f7b4a --- /dev/null +++ b/src/types/WtcConfig.ts @@ -0,0 +1,20 @@ +import { Dayjs } from 'dayjs'; +import { Duration } from 'dayjs/plugin/duration.js'; +import Language from './Language.js'; + +export default interface WtcConfig { + language: Language, + defaults: { + workDayDuration: Duration; + lunchBreakDuration: Duration; + startTime: Dayjs; + stopTime: Dayjs; + }; + askInput: { + workDayLength: boolean; + startTime: boolean; + stopTime: boolean; + logged: boolean; + hadLunch: boolean; + }; +} diff --git a/src/ui.ts b/src/ui.ts index 1cc4bc5..45d0285 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -1,9 +1,11 @@ +import getConfig from './config.js'; import input from './input.js'; import output from './output.js'; const ui = async () => { - const result = await input(); - output(result); + const config = getConfig(); + const result = await input(config); + output(result, config); }; export default ui;