Add i18n support and translations for finnish language

This commit is contained in:
Marko Korhonen 2023-11-23 17:24:08 +02:00
parent b88e19e311
commit 4b8c8be226
Signed by: FunctionalHacker
GPG key ID: A7F78BCB859CD890
9 changed files with 239 additions and 84 deletions

View file

@ -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

View file

@ -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<Config, 'defaults'> {
interface RawConfig extends Omit<WtcConfig, 'defaults'> {
defaults: {
workDayDuration: string;
lunchBreakDuration: string;
@ -32,6 +16,7 @@ interface RawConfig extends Omit<Config, 'defaults'> {
}
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,

View file

@ -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 => {
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
let 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' : ''}]`;
}
return duration.format(formatString);
};
export const getHoursRoundedStr = (duration: Duration) =>
`(${getHoursRounded(duration)} as hours rounded to next even 15 minutes)`;
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

120
src/i18n.ts Normal file
View file

@ -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, Record<Language, string>> = {
[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;
};

View file

@ -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<WtcPromptResult> => {
const { defaults, askInput } = getConfig();
const input = async (config: WtcConfig): Promise<WtcPromptResult> => {
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<WtcPromptResult> => {
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<WtcPromptResult> => {
}
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<WtcPromptResult> => {
}
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<WtcPromptResult> => {
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';
}

View file

@ -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)));
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) {
log(msg(MessageKey.startedWorking), formatTimestamp(startedAt));
log(
chalk.red(`You have logged ${formatDuration(unLogged)} more than you worked today!`),
chalk.yellow(getHoursRoundedStr(unLogged)),
(stoppedWorking ? msg(MessageKey.stoppedWorking) : msg(MessageKey.hoursCalculated)) +
` ${msg(MessageKey.klo)}:`,
formatTimestamp(stoppedAt),
);
log(msg(MessageKey.workedToday), chalk.green(fmtDuration(worked)), chalk.yellow(hoursRounded(worked)));
const unLoggedMinutes = unLogged.asMinutes();
if (unLoggedMinutes >= 0) {
log(
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))));
}
};

6
src/types/Language.ts Normal file
View file

@ -0,0 +1,6 @@
enum Language {
en = 'en',
fi = 'fi',
}
export default Language;

20
src/types/WtcConfig.ts Normal file
View file

@ -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;
};
}

View file

@ -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;