From 357b20a7c8c1bd6e0d1efc51cfd013ca4ae426f9 Mon Sep 17 00:00:00 2001 From: tirinnovo Date: Sat, 22 Mar 2025 22:22:31 +0100 Subject: [PATCH] Add i18n support --- README.md | 45 +++++++++++++++++ src/defaults.js | 12 +++-- src/i18n.js | 109 ++++++++++++++++++++++++++++++++++++++++ src/index.js | 20 +++++--- src/locales/de-DE.json | 31 ++++++++++++ src/locales/en-UK.json | 31 ++++++++++++ src/locales/en-US.json | 31 ++++++++++++ src/locales/es-ES.json | 31 ++++++++++++ src/locales/fr-FR.json | 31 ++++++++++++ src/locales/index.json | 15 ++++++ src/locales/it-IT.json | 31 ++++++++++++ tests/i18n/i18n.test.js | 80 +++++++++++++++++++++++++++++ 12 files changed, 457 insertions(+), 10 deletions(-) create mode 100644 src/i18n.js create mode 100644 src/locales/de-DE.json create mode 100644 src/locales/en-UK.json create mode 100644 src/locales/en-US.json create mode 100644 src/locales/es-ES.json create mode 100644 src/locales/fr-FR.json create mode 100644 src/locales/index.json create mode 100644 src/locales/it-IT.json create mode 100644 tests/i18n/i18n.test.js diff --git a/README.md b/README.md index 5195f34d4..b84cd1b34 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,51 @@ Frappe Gantt exposes a few helpful methods for you to interact with the chart: | `.scroll_current` | Scrolls to the current date | No parameters. | | `.update_task` | Re-renders a specific task bar alone | `task_id` - id of task and `new_details` - object containing the task properties to be updated. | +## Internationalization (i18n) + +The Gantt chart supports multiple languages. By default, it's in English, but you can easily switch to other languages: + +### Basic usage + +```javascript +import Gantt from 'frappe-gantt'; + +// Create Gantt chart with English (default) +const gantt = new Gantt('#gantt', tasks); +``` + +### Using a specific language + +```javascript +import Gantt from 'frappe-gantt'; + +const gantt = new Gantt('#gantt', tasks, { + language: 'it', // or 'it-IT' +}); +``` + +### Adding custom translations + +You can also create your own translations: + +```javascript +import Gantt from 'frappe-gantt'; + +const myEoLocale = { + Mode: 'Modo', + Today: 'Hodiaŭ', + Year: 'Jaro', + // ... +}; + +const gantt = new Gantt('#gantt', tasks, { + language: 'eo', + locales: { + 'eo': myEoLocale, + }, +}); +``` + ## Development Setup If you want to contribute enhancements or fixes: diff --git a/src/defaults.js b/src/defaults.js index e63e8ef5c..4a5ccd546 100644 --- a/src/defaults.js +++ b/src/defaults.js @@ -1,4 +1,5 @@ import date_utils from './date_utils'; +import { gettext } from './i18n'; function getDecade(d) { const year = d.getFullYear(); @@ -130,19 +131,24 @@ const DEFAULT_OPTIONS = { if (ctx.task.description) ctx.set_subtitle(ctx.task.description); else ctx.set_subtitle(''); + const lang = ctx.chart.options.language; const start_date = date_utils.format( ctx.task._start, 'MMM D', - ctx.chart.options.language, + lang, ); const end_date = date_utils.format( date_utils.add(ctx.task._end, -1, 'second'), 'MMM D', - ctx.chart.options.language, + lang, ); + const excluded_text = ctx.task.ignored_duration + ? ` + ${ctx.task.ignored_duration} ${gettext('excluded', lang)}` + : ''; + ctx.set_details( - `${start_date} - ${end_date} (${ctx.task.actual_duration} days${ctx.task.ignored_duration ? ' + ' + ctx.task.ignored_duration + ' excluded' : ''})
Progress: ${Math.floor(ctx.task.progress * 100) / 100}%`, + `${gettext('Dates', lang)}: ${start_date} - ${end_date} (${ctx.task.actual_duration} ${gettext(ctx.task.actual_duration === 1 ? 'day' : 'days', lang)}${excluded_text})
${gettext('Progress', lang)}: ${Math.floor(ctx.task.progress * 100) / 100}%`, ); }, popup_on: 'click', diff --git a/src/i18n.js b/src/i18n.js new file mode 100644 index 000000000..dc857bf02 --- /dev/null +++ b/src/i18n.js @@ -0,0 +1,109 @@ +// Import all locale files directly +import en_US from './locales/en-US.json'; +import it_IT from './locales/it-IT.json'; +import fr_FR from './locales/fr-FR.json'; +import es_ES from './locales/es-ES.json'; +import de_DE from './locales/de-DE.json'; + +// Static map of all available locales +const locales = { + 'en': en_US, + 'en-US': en_US, + 'it': it_IT, + 'it-IT': it_IT, + 'fr': fr_FR, + 'fr-FR': fr_FR, + 'es': es_ES, + 'es-ES': es_ES, + 'de': de_DE, + 'de-DE': de_DE +}; + +/** + * Add custom locales to the available locales + * @param {Object} customLocales - Object containing custom locale translations + */ +export function addLocales(customLocales) { + if (!customLocales) return; + + // Merge custom locales with existing ones + Object.keys(customLocales).forEach(langCode => { + locales[langCode] = customLocales[langCode]; + }); +} + +/** + * Get a translation for a key in the specified language + * @param {string} key - The translation key + * @param {string} lang - The language code (defaults to 'en') + * @param {Object} params - Parameters to replace in the translation + * @returns {string} The translated text or the key if not found + */ +export function translate(key, lang = 'en', params = {}) { + // Get the appropriate locale or fall back to English + const langCode = normalizeLangCode(lang); + const locale = locales[langCode] || locales['en']; + + if (!locale) { + return key; + } + + let text = locale[key] || key; + + // Replace any parameters in the text + if (params && Object.keys(params).length > 0) { + Object.keys(params).forEach(param => { + text = text.replace(new RegExp(`{${param}}`, 'g'), params[param]); + }); + } + + return text; +} + +/** + * Alias for translate function for backward compatibility + */ +export function gettext(key, lang, params) { + return translate(key, lang, params); +} + +/** + * Normalize language code to find the most appropriate match + * @param {string} langCode - The language code to normalize + * @returns {string} The normalized language code + */ +function normalizeLangCode(langCode) { + if (!langCode) return 'en'; + + // First check exact match + if (locales[langCode]) return langCode; + + // Check language part only (e.g., 'en' from 'en-US') + const mainLang = langCode.split('-')[0]; + if (locales[mainLang]) return mainLang; + + // Check for any variant of the language + const allKeys = Object.keys(locales); + for (let i = 0; i < allKeys.length; i++) { + if (allKeys[i].startsWith(mainLang + '-')) { + return allKeys[i]; + } + } + + return 'en'; +} + +/** + * Get all available languages + * @returns {Object} An object with language codes as keys + */ +export function getAvailableLanguages() { + return Object.keys(locales); +} + +export default { + translate, + gettext, + getAvailableLanguages, + addLocales +}; diff --git a/src/index.js b/src/index.js index 054d3e2ce..76cde29df 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,5 @@ import date_utils from './date_utils'; +import { gettext, addLocales } from './i18n'; import { $, createSVG } from './svg_utils'; import Arrow from './arrow'; @@ -13,6 +14,9 @@ export default class Gantt { constructor(wrapper, tasks, options) { this.setup_wrapper(wrapper); this.setup_options(options); + if (options.locales) { + addLocales(options.locales); + } this.setup_tasks(tasks); this.change_view_mode(); this.bind_events(); @@ -124,7 +128,7 @@ export default class Gantt { .map((task, i) => { if (!task.start) { console.error( - `task "${task.id}" doesn't have a start date`, + gettext('task_no_start_date', this.options.language, { id: task.id || '' }), ); return false; } @@ -141,7 +145,9 @@ export default class Gantt { }); } if (!task.end) { - console.error(`task "${task.id}" doesn't have an end date`); + console.error( + gettext('task_no_end_date', this.options.language, { id: task.id || '' }), + ); return false; } task._end = date_utils.parse(task.end); @@ -149,7 +155,7 @@ export default class Gantt { let diff = date_utils.diff(task._end, task._start, 'year'); if (diff < 0) { console.error( - `start of task can't be after end of task: in task "${task.id}"`, + gettext('task_start_after_end', this.options.language, { id: task.id || '' }), ); return false; } @@ -157,7 +163,7 @@ export default class Gantt { // make task invalid if duration too large if (date_utils.diff(task._end, task._start, 'year') > 10) { console.error( - `the duration of task "${task.id}" is too long (above ten years)`, + gettext('task_duration_too_long', this.options.language, { id: task.id || '' }), ); return false; } @@ -476,13 +482,13 @@ export default class Gantt { const $el = document.createElement('option'); $el.selected = true; $el.disabled = true; - $el.textContent = 'Mode'; + $el.textContent = gettext('Mode', this.options.language); $select.appendChild($el); for (const mode of this.options.view_modes) { const $option = document.createElement('option'); $option.value = mode.name; - $option.textContent = mode.name; + $option.textContent = gettext(mode.name, this.options.language); if (mode.name === this.config.view_mode.name) $option.selected = true; $select.appendChild($option); @@ -501,7 +507,7 @@ export default class Gantt { if (this.options.today_button) { let $today_button = document.createElement('button'); $today_button.classList.add('today-button'); - $today_button.textContent = 'Today'; + $today_button.textContent = gettext('Today', this.options.language); $today_button.onclick = this.scroll_current.bind(this); this.$side_header.prepend($today_button); this.$today_button = $today_button; diff --git a/src/locales/de-DE.json b/src/locales/de-DE.json new file mode 100644 index 000000000..6252057c7 --- /dev/null +++ b/src/locales/de-DE.json @@ -0,0 +1,31 @@ +{ + "Mode": "Modus", + "Today": "Heute", + "Year": "Jahr", + "Month": "Monat", + "Week": "Woche", + "Day": "Tag", + "Hour": "Stunde", + "Quarter Day": "Viertel Tag", + "Half Day": "Halber Tag", + "year": "Jahr", + "years": "Jahre", + "month": "Monat", + "months": "Monate", + "day": "Tag", + "days": "Tage", + "hour": "Stunde", + "hours": "Stunden", + "minute": "Minute", + "minutes": "Minuten", + "second": "Sekunde", + "seconds": "Sekunden", + "Progress": "Fortschritt", + "Duration": "Dauer", + "Dates": "Termine", + "excluded": "ausgeschlossen", + "task_no_start_date": "Aufgabe \"{id}\" hat kein Startdatum", + "task_no_end_date": "Aufgabe \"{id}\" hat kein Enddatum", + "task_start_after_end": "Der Start der Aufgabe kann nicht nach dem Ende der Aufgabe liegen: in Aufgabe \"{id}\"", + "task_duration_too_long": "Die Dauer der Aufgabe \"{id}\" ist zu lang (über zehn Jahre)" +} \ No newline at end of file diff --git a/src/locales/en-UK.json b/src/locales/en-UK.json new file mode 100644 index 000000000..8ddc8fa14 --- /dev/null +++ b/src/locales/en-UK.json @@ -0,0 +1,31 @@ +{ + "Mode": "Mode", + "Today": "Today", + "Year": "Year", + "Month": "Month", + "Week": "Week", + "Day": "Day", + "Hour": "Hour", + "Quarter Day": "Quarter Day", + "Half Day": "Half Day", + "year": "year", + "years": "years", + "month": "month", + "months": "months", + "day": "day", + "days": "days", + "hour": "hour", + "hours": "hours", + "minute": "minute", + "minutes": "minutes", + "second": "second", + "seconds": "seconds", + "Progress": "Progress", + "Duration": "Duration", + "Dates": "Dates", + "excluded": "excluded", + "task_no_start_date": "task \"{id}\" doesn't have a start date", + "task_no_end_date": "task \"{id}\" doesn't have an end date", + "task_start_after_end": "start of task can't be after end of task: in task \"{id}\"", + "task_duration_too_long": "the duration of task \"{id}\" is too long (above ten years)" +} \ No newline at end of file diff --git a/src/locales/en-US.json b/src/locales/en-US.json new file mode 100644 index 000000000..8ddc8fa14 --- /dev/null +++ b/src/locales/en-US.json @@ -0,0 +1,31 @@ +{ + "Mode": "Mode", + "Today": "Today", + "Year": "Year", + "Month": "Month", + "Week": "Week", + "Day": "Day", + "Hour": "Hour", + "Quarter Day": "Quarter Day", + "Half Day": "Half Day", + "year": "year", + "years": "years", + "month": "month", + "months": "months", + "day": "day", + "days": "days", + "hour": "hour", + "hours": "hours", + "minute": "minute", + "minutes": "minutes", + "second": "second", + "seconds": "seconds", + "Progress": "Progress", + "Duration": "Duration", + "Dates": "Dates", + "excluded": "excluded", + "task_no_start_date": "task \"{id}\" doesn't have a start date", + "task_no_end_date": "task \"{id}\" doesn't have an end date", + "task_start_after_end": "start of task can't be after end of task: in task \"{id}\"", + "task_duration_too_long": "the duration of task \"{id}\" is too long (above ten years)" +} \ No newline at end of file diff --git a/src/locales/es-ES.json b/src/locales/es-ES.json new file mode 100644 index 000000000..dfff935b3 --- /dev/null +++ b/src/locales/es-ES.json @@ -0,0 +1,31 @@ +{ + "Mode": "Modo", + "Today": "Hoy", + "Year": "Año", + "Month": "Mes", + "Week": "Semana", + "Day": "Día", + "Hour": "Hora", + "Quarter Day": "Cuarto de día", + "Half Day": "Medio día", + "year": "año", + "years": "años", + "month": "mes", + "months": "meses", + "day": "día", + "days": "días", + "hour": "hora", + "hours": "horas", + "minute": "minuto", + "minutes": "minutos", + "second": "segundo", + "seconds": "segundos", + "Progress": "Progreso", + "Duration": "Duración", + "Dates": "Fechas", + "excluded": "excluido", + "task_no_start_date": "la tarea \"{id}\" no tiene fecha de inicio", + "task_no_end_date": "la tarea \"{id}\" no tiene fecha de finalización", + "task_start_after_end": "el inicio de la tarea no puede ser posterior al final de la tarea: en la tarea \"{id}\"", + "task_duration_too_long": "la duración de la tarea \"{id}\" es demasiado larga (más de diez años)" +} \ No newline at end of file diff --git a/src/locales/fr-FR.json b/src/locales/fr-FR.json new file mode 100644 index 000000000..7ab903ecf --- /dev/null +++ b/src/locales/fr-FR.json @@ -0,0 +1,31 @@ +{ + "Mode": "Mode", + "Today": "Aujourd'hui", + "Year": "Année", + "Month": "Mois", + "Week": "Semaine", + "Day": "Jour", + "Hour": "Heure", + "Quarter Day": "Quart de journée", + "Half Day": "Demi-journée", + "year": "année", + "years": "années", + "month": "mois", + "months": "mois", + "day": "jour", + "days": "jours", + "hour": "heure", + "hours": "heures", + "minute": "minute", + "minutes": "minutes", + "second": "seconde", + "seconds": "secondes", + "Progress": "Progression", + "Duration": "Durée", + "Dates": "Dates", + "excluded": "exclu", + "task_no_start_date": "la tâche \"{id}\" n'a pas de date de début", + "task_no_end_date": "la tâche \"{id}\" n'a pas de date de fin", + "task_start_after_end": "le début de la tâche ne peut pas être après la fin de la tâche: dans la tâche \"{id}\"", + "task_duration_too_long": "la durée de la tâche \"{id}\" est trop longue (plus de dix ans)" +} \ No newline at end of file diff --git a/src/locales/index.json b/src/locales/index.json new file mode 100644 index 000000000..3166586f7 --- /dev/null +++ b/src/locales/index.json @@ -0,0 +1,15 @@ +{ + "locales": { + "en": "en-US.json", + "en-US": "en-US.json", + "en-UK": "en-UK.json", + "it": "it-IT.json", + "it-IT": "it-IT.json", + "fr": "fr-FR.json", + "fr-FR": "fr-FR.json", + "es": "es-ES.json", + "es-ES": "es-ES.json", + "de": "de-DE.json", + "de-DE": "de-DE.json" + } +} \ No newline at end of file diff --git a/src/locales/it-IT.json b/src/locales/it-IT.json new file mode 100644 index 000000000..a7f0f1e03 --- /dev/null +++ b/src/locales/it-IT.json @@ -0,0 +1,31 @@ +{ + "Mode": "Modalità", + "Today": "Oggi", + "Year": "Anno", + "Month": "Mese", + "Week": "Settimana", + "Day": "Giorno", + "Hour": "Ora", + "Quarter Day": "Quarto di giorno", + "Half Day": "Mezza giornata", + "year": "anno", + "years": "anni", + "month": "mese", + "months": "mesi", + "day": "giorno", + "days": "giorni", + "hour": "ora", + "hours": "ore", + "minute": "minuto", + "minutes": "minuti", + "second": "secondo", + "seconds": "secondi", + "Progress": "Progresso", + "Duration": "Durata", + "Dates": "Date", + "excluded": "escluso", + "task_no_start_date": "l'attività \"{id}\" non ha una data di inizio", + "task_no_end_date": "l'attività \"{id}\" non ha una data di fine", + "task_start_after_end": "l'inizio dell'attività non può essere dopo la fine dell'attività: nell'attività \"{id}\"", + "task_duration_too_long": "la durata dell'attività \"{id}\" è troppo lunga (superiore a dieci anni)" +} \ No newline at end of file diff --git a/tests/i18n/i18n.test.js b/tests/i18n/i18n.test.js new file mode 100644 index 000000000..e7a132c77 --- /dev/null +++ b/tests/i18n/i18n.test.js @@ -0,0 +1,80 @@ +import { translate, gettext, getAvailableLanguages } from '../../src/i18n'; + +describe('i18n core functionality', () => { + test('translate returns original key for unknown language', () => { + const result = translate('unknown_key', 'xx-XX'); + expect(result).toBe('unknown_key'); + }); + + test('translate returns original key for unknown translation', () => { + const result = translate('unknown_key', 'en'); + expect(result).toBe('unknown_key'); + }); + + test('translate returns correct translation for English', () => { + const result = translate('Today', 'en'); + expect(result).toBe('Today'); + }); + + test('gettext is an alias for translate', () => { + const translateResult = translate('Today', 'en'); + const gettextResult = gettext('Today', 'en'); + expect(translateResult).toBe(gettextResult); + }); + + test('translate replaces parameters in translation', () => { + const result = translate('task_no_start_date', 'en', { id: '123' }); + expect(result).toContain('123'); + }); + + test('translate handles various language codes', () => { + // Test full language code + const resultEnUS = translate('Today', 'en-US'); + expect(resultEnUS).toBe('Today'); + + // Test main language code + const resultEn = translate('Today', 'en'); + expect(resultEn).toBe('Today'); + + // Test for language variants + const resultIt = translate('Today', 'it'); + expect(resultIt).toBe('Oggi'); + + const resultFr = translate('Today', 'fr'); + expect(resultFr).toBe("Aujourd'hui"); + }); + + test('translate falls back to main language code', () => { + // Use a region-specific variant not directly defined + const result = translate('Today', 'en-CUSTOM'); + + // Should fall back to main + expect(result).toBe('Today'); + }); + + test('translate handles missing language gracefully', () => { + const result = translate('Some Text', null); + expect(result).toBe('Some Text'); + }); + + test('translate handles null parameters gracefully', () => { + const result = translate('Today', 'en', null); + expect(result).toBe('Today'); + }); + + test('getAvailableLanguages returns all available languages', () => { + const languages = getAvailableLanguages(); + + // Should contain all our statically loaded languages + expect(languages).toContain('en'); + expect(languages).toContain('en-US'); + expect(languages).toContain('it'); + expect(languages).toContain('it-IT'); + expect(languages).toContain('fr'); + expect(languages).toContain('fr-FR'); + expect(languages).toContain('es'); + expect(languages).toContain('es-ES'); + expect(languages).toContain('de'); + expect(languages).toContain('de-DE'); + }); +});