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');
+ });
+});