This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new 590e589bf2 Issue apache#2814 [Bug]: changing GUI language changes also
decimal separator (#8159)
590e589bf2 is described below
commit 590e589bf2c22cea818a0d1096aff59999c76de3
Author: Sergio Ramazzina <[email protected]>
AuthorDate: Wed Oct 28 13:03:26 2026 +0100
Issue apache#2814 [Bug]: changing GUI language changes also decimal
separator (#8159)
* Issue apache#2814 [Bug]: changing GUI language changes also decimal
separator
* The interface language was installed as the JVM-wide default locale, so it
also decided decimal separator, grouping separator, currency and date
formats. The language now writes only Locale.Category.DISPLAY and the
regional settings write Locale.Category.FORMAT.
* Regional settings had no representation of their own. Added
RegionalSettings
in core, resolving a single source - OPERATING_SYSTEM (the default),
CUSTOM
or LANGUAGE - from RegionalSettingsSource and RegionalSettingsLocale in
hop-config.json; an unreadable or unknown configuration degrades to
OPERATING_SYSTEM.
* Locale.setDefault lived in HopGui.main, so hop-run and hop-server never
applied it and the same pipeline formatted differently in the editor and
in
production. The regional settings are now applied from
HopEnvironment.init(),
the bootstrap common to every entry point.
* Const.DEFAULT_DECIMAL_SEPARATOR and its siblings are static final,
computed
at class load before any locale was installed, and ValueMetaBase copied
them
into every value, overwriting correctly localised symbols. Added
getDefaultDecimalSeparator(), getDefaultGroupingSeparator(),
getDefaultCurrencySymbol() and getDefaultNumberFormat(), which read the
live
FORMAT category and cache the symbols against the locale they were built
from; the constants are kept and deprecated because they are public API.
* The setting was not reachable from the GUI. Added a Regional settings tab
carrying the interface language combo moved out of GUI options, two
mutually
exclusive checkboxes, a type-to-filter combo over the available locales
and a
live preview of the resulting formats.
* ValueMetaBase and ValueMetaTimestamp compared an explicitly chosen date
format locale against Locale.getDefault(), so a field locale that
happened to
match the interface language was discarded and the date rendered with the
regional locale instead. The comparison now reads the FORMAT category.
Fixes #2814
* Issue #2814 : FORMAT date locale, log effective regional settings
ValueMetaBase copied Locale.getDefault() into every field, which is the
interface language once DISPLAY and FORMAT are split. New fields now take
the FORMAT category so month names follow regional settings, not the GUI
language.
hop-gui, hop-run and hop-server log the effective language, FORMAT locale
and timezone at start so a machine-local mismatch is visible.
Docs: field-level symbols still win; default OS source still differs
across machines unless Override is set; Beam/Spark workers do not load
hop-config.json.
* Issue #2814 : FORMAT locale for the OS default and collation, lock-free
format defaults
---------
Co-authored-by: mattcasters <[email protected]>
Co-authored-by: Hans Van Akelyen <[email protected]>
---
.../main/java/org/apache/hop/core/Condition.java | 2 +-
core/src/main/java/org/apache/hop/core/Const.java | 107 ++++-
.../apache/hop/core/row/value/ValueMetaBase.java | 23 +-
.../hop/core/row/value/ValueMetaTimestamp.java | 8 +-
.../java/org/apache/hop/i18n/RegionalSettings.java | 225 +++++++++
.../apache/hop/i18n/RegionalSettingsPreview.java | 104 ++++
.../java/org/apache/hop/core/ConstLocaleTest.java | 69 +++
.../row/value/ValueMetaDateFormatLocaleTest.java | 141 ++++++
.../apache/hop/i18n/RegionalSettingsApplyTest.java | 169 +++++++
.../hop/i18n/RegionalSettingsPreviewTest.java | 87 ++++
.../org/apache/hop/i18n/RegionalSettingsTest.java | 103 ++++
.../pages/hop-gui/perspective-configuration.adoc | 76 ++-
.../java/org/apache/hop/core/HopEnvironment.java | 6 +
.../apache/hop/core/file/TextFileInputField.java | 5 +-
.../pipeline/transforms/file/BaseFileField.java | 5 +-
.../googlesheets/GoogleSheetsInputField.java | 5 +-
.../transforms/csvinput/CsvInputField.java | 5 +-
.../main/java/org/apache/hop/ui/hopgui/HopGui.java | 5 +-
.../configuration/tabs/ConfigGuiOptionsTab.java | 40 --
.../tabs/ConfigRegionalSettingsTab.java | 532 +++++++++++++++++++++
.../core/dialog/messages/messages_en_US.properties | 16 +
21 files changed, 1668 insertions(+), 65 deletions(-)
diff --git a/core/src/main/java/org/apache/hop/core/Condition.java
b/core/src/main/java/org/apache/hop/core/Condition.java
index 0c743ff545..de5a873a5e 100644
--- a/core/src/main/java/org/apache/hop/core/Condition.java
+++ b/core/src/main/java/org/apache/hop/core/Condition.java
@@ -856,7 +856,7 @@ public class Condition implements Cloneable {
IValueMeta valueMeta = ValueMetaFactory.createValueMeta(name,
getHopType());
valueMeta.setLength(length, precision);
valueMeta.setConversionMask(mask);
-
valueMeta.setDecimalSymbol(String.valueOf(Const.DEFAULT_DECIMAL_SEPARATOR));
+
valueMeta.setDecimalSymbol(String.valueOf(Const.getDefaultDecimalSeparator()));
valueMeta.setGroupingSymbol(null);
valueMeta.setCurrencySymbol(null);
return valueMeta;
diff --git a/core/src/main/java/org/apache/hop/core/Const.java
b/core/src/main/java/org/apache/hop/core/Const.java
index 0aeadf8c8f..a8331426a0 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -300,22 +300,119 @@ public class Const {
/** The default locale for the hop environment (system defined) */
public static final Locale DEFAULT_LOCALE = Locale.getDefault();
- /** The default decimal separator . or , */
+ /**
+ * The default decimal separator . or ,
+ *
+ * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use
{@link
+ * #getDefaultDecimalSeparator()} to read the active regional settings
at call time.
+ */
+ @Deprecated(since = "2.20")
public static final char DEFAULT_DECIMAL_SEPARATOR =
(new DecimalFormatSymbols(DEFAULT_LOCALE)).getDecimalSeparator();
- /** The default grouping separator , or . */
+ /**
+ * The default grouping separator , or .
+ *
+ * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use
{@link
+ * #getDefaultGroupingSeparator()} to read the active regional settings
at call time.
+ */
+ @Deprecated(since = "2.20")
public static final char DEFAULT_GROUPING_SEPARATOR =
(new DecimalFormatSymbols(DEFAULT_LOCALE)).getGroupingSeparator();
- /** The default currency symbol */
+ /**
+ * The default currency symbol
+ *
+ * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use
{@link
+ * #getDefaultCurrencySymbol()} to read the active regional settings at
call time.
+ */
+ @Deprecated(since = "2.20")
public static final String DEFAULT_CURRENCY_SYMBOL =
(new DecimalFormatSymbols(DEFAULT_LOCALE)).getCurrencySymbol();
- /** The default number format */
+ /**
+ * The default number format
+ *
+ * @deprecated captured at class-load time from {@link #DEFAULT_LOCALE}; use
{@link
+ * #getDefaultNumberFormat()} to read the active regional settings at
call time.
+ */
+ @Deprecated(since = "2.20")
public static final String DEFAULT_NUMBER_FORMAT =
((DecimalFormat) (NumberFormat.getInstance())).toPattern();
+ /** The format defaults of one FORMAT locale. Immutable, so it can be shared
without locking. */
+ private record FormatDefaults(
+ Locale locale,
+ char decimalSeparator,
+ char groupingSeparator,
+ String currencySymbol,
+ String numberFormat) {
+
+ static FormatDefaults of(Locale locale) {
+ DecimalFormatSymbols symbols = new DecimalFormatSymbols(locale);
+ return new FormatDefaults(
+ locale,
+ symbols.getDecimalSeparator(),
+ symbols.getGroupingSeparator(),
+ symbols.getCurrencySymbol(),
+ ((DecimalFormat) NumberFormat.getInstance(locale)).toPattern());
+ }
+ }
+
+ /**
+ * The format defaults for the FORMAT locale they were built from.
+ *
+ * <p>These accessors are called from the {@code ValueMetaBase} constructor,
so they sit on a hot
+ * path: building a {@link DecimalFormatSymbols} on every call would be a
real cost — the same one
+ * {@code ValueMetaBase.getDecimalFormat()} already warns about for {@code
DecimalFormat}. The
+ * defaults are therefore cached and rebuilt only when the FORMAT locale
actually changes.
+ *
+ * <p>The snapshot is immutable and published through a volatile field, so
readers never lock and
+ * never pair one locale's symbols with another's. Two threads that see a
locale change at the
+ * same time may both build a snapshot; both are correct and the last write
wins.
+ */
+ private static volatile FormatDefaults formatDefaults;
+
+ private static FormatDefaults getFormatDefaults() {
+ Locale formatLocale = Locale.getDefault(Locale.Category.FORMAT);
+ FormatDefaults defaults = formatDefaults;
+ if (defaults == null || !formatLocale.equals(defaults.locale())) {
+ defaults = FormatDefaults.of(formatLocale);
+ formatDefaults = defaults;
+ }
+ return defaults;
+ }
+
+ /**
+ * The decimal separator of the active regional settings, read at call time.
+ *
+ * <p>Prefer this over {@link #DEFAULT_DECIMAL_SEPARATOR}, which is captured
when the class is
+ * loaded and therefore predates the regional settings being installed.
+ */
+ public static char getDefaultDecimalSeparator() {
+ return getFormatDefaults().decimalSeparator();
+ }
+
+ /** The grouping separator of the active regional settings, read at call
time. */
+ public static char getDefaultGroupingSeparator() {
+ return getFormatDefaults().groupingSeparator();
+ }
+
+ /** The currency symbol of the active regional settings, read at call time.
*/
+ public static String getDefaultCurrencySymbol() {
+ return getFormatDefaults().currencySymbol();
+ }
+
+ /**
+ * The number format pattern of the active regional settings, read at call
time. In practice the
+ * returned pattern is locale-invariant (locale-specific separators are
applied later via
+ * DecimalFormatSymbols): on JDK 21 every available locale yields {@code
#,##0.###} except
+ * en-US-POSIX, so callers do not generally need to re-read it when the
locale changes.
+ */
+ public static String getDefaultNumberFormat() {
+ return getFormatDefaults().numberFormat();
+ }
+
/** Default string representing Null String values (empty) */
public static final String NULL_STRING = "";
@@ -3014,7 +3111,7 @@ public class Const {
if (numberFormats == null) {
int numberFormatsCount = toInt(BaseMessages.getString(PKG,
"Const.NumberFormat.Count"), 0);
numberFormats = new String[numberFormatsCount + 1];
- numberFormats[0] = DEFAULT_NUMBER_FORMAT;
+ numberFormats[0] = getDefaultNumberFormat();
for (int i = 1; i <= numberFormatsCount; i++) {
numberFormats[i] = BaseMessages.getString(PKG, "Const.NumberFormat" +
i);
}
diff --git
a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java
b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java
index eac1795b7d..8daa1301f7 100644
--- a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java
+++ b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaBase.java
@@ -313,12 +313,17 @@ public class ValueMetaBase implements IValueMeta {
this.storageType = STORAGE_TYPE_NORMAL;
this.sortedDescending = false;
this.outputPaddingEnabled = false;
- this.decimalSymbol = "" + Const.DEFAULT_DECIMAL_SEPARATOR;
- this.groupingSymbol = "" + Const.DEFAULT_GROUPING_SEPARATOR;
- this.currencySymbol = "" + Const.DEFAULT_CURRENCY_SYMBOL;
- this.dateFormatLocale = Locale.getDefault();
+ this.decimalSymbol = "" + Const.getDefaultDecimalSeparator();
+ this.groupingSymbol = "" + Const.getDefaultGroupingSeparator();
+ this.currencySymbol = "" + Const.getDefaultCurrencySymbol();
+ // FORMAT, not Locale.getDefault(): the latter is the interface language
once DISPLAY and
+ // FORMAT are split, and a field with no explicit date locale must follow
the regional
+ // settings rather than the GUI language.
+ this.dateFormatLocale = Locale.getDefault(Locale.Category.FORMAT);
this.collatorDisabled = true;
- this.collatorLocale = Locale.getDefault();
+ // FORMAT for the same reason: sort order is a regional setting, and a
sort must not change
+ // with the GUI language or differ between Hop GUI and hop-run.
+ this.collatorLocale = Locale.getDefault(Locale.Category.FORMAT);
this.collator = Collator.getInstance(this.collatorLocale);
this.collatorStrength = 0;
this.dateFormatTimeZone = TimeZone.getDefault();
@@ -1356,7 +1361,13 @@ public class ValueMetaBase implements IValueMeta {
// Do we have a locale?
//
- if (dateFormatLocale == null ||
dateFormatLocale.equals(Locale.getDefault())) {
+ // Compared against the FORMAT category, not against
Locale.getDefault(): that one carries the
+ // interface language, so a locale deliberately picked on the field
would be dismissed as "no
+ // locale set" whenever it happened to match the language, and the field
would silently follow
+ // the regional settings instead of the choice.
+ //
+ if (dateFormatLocale == null
+ ||
dateFormatLocale.equals(Locale.getDefault(Locale.Category.FORMAT))) {
if (mask != null) {
dateFormat = new SimpleDateFormat(mask);
}
diff --git
a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java
b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java
index 18e7b8a6c5..bd5f556d9b 100644
--- a/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java
+++ b/core/src/main/java/org/apache/hop/core/row/value/ValueMetaTimestamp.java
@@ -666,7 +666,13 @@ public class ValueMetaTimestamp extends ValueMetaDate {
// Do we have a locale?
//
- if (dateFormatLocale == null ||
dateFormatLocale.equals(Locale.getDefault())) {
+ // Compared against the FORMAT category, not against
Locale.getDefault(): that one carries the
+ // interface language, so a locale deliberately picked on the field
would be dismissed as "no
+ // locale set" whenever it happened to match the language, and the field
would silently follow
+ // the regional settings instead of the choice.
+ //
+ if (dateFormatLocale == null
+ ||
dateFormatLocale.equals(Locale.getDefault(Locale.Category.FORMAT))) {
dateFormat = new SimpleTimestampFormat(mask);
} else {
dateFormat = new SimpleTimestampFormat(mask, dateFormatLocale);
diff --git a/core/src/main/java/org/apache/hop/i18n/RegionalSettings.java
b/core/src/main/java/org/apache/hop/i18n/RegionalSettings.java
new file mode 100644
index 0000000000..8ed0d440c1
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/i18n/RegionalSettings.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.TimeZone;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.core.util.Utils;
+
+/**
+ * Holds the regional settings (decimal and grouping separators, currency,
date formats) as a
+ * concern separate from the interface language, which stays under {@link
LanguageChoice}.
+ *
+ * <p>The effective locale is installed in the JVM as {@link
Locale.Category#FORMAT}, while {@link
+ * Locale#getDefault()} — the locale {@code ResourceBundle} resolves messages
with — keeps carrying
+ * the interface language.
+ */
+public class RegionalSettings {
+
+ /** Where the regional settings come from. */
+ public enum Source {
+ /**
+ * Follow the selected interface language, so that changing the language
changes the formats
+ * with it. This is a deliberate choice a user makes, not the source an
unconfigured
+ * installation falls back to.
+ */
+ LANGUAGE,
+ /** Inherit them from the operating system Hop is running on. */
+ OPERATING_SYSTEM,
+ /** Use an explicitly selected locale. */
+ CUSTOM
+ }
+
+ public static final String STRING_REGIONAL_SETTINGS_SOURCE =
"RegionalSettingsSource";
+ public static final String STRING_REGIONAL_SETTINGS_LOCALE =
"RegionalSettingsLocale";
+
+ /**
+ * The locale the JVM started with, captured before anything can overwrite
it. The first {@code
+ * Locale.setDefault(language)} destroys this value and it cannot be
recovered afterwards, so
+ * {@link Source#OPERATING_SYSTEM} would have nothing to read without this
field.
+ *
+ * <p>It is the FORMAT category, not {@link Locale#getDefault()}: on Windows
the JVM starts with
+ * the display language there and the regional format (user.language.format)
in FORMAT, so an
+ * English Windows with Belgian regional settings would otherwise lose its
{@code ,} decimal.
+ */
+ private static final Locale OPERATING_SYSTEM_LOCALE =
captureOperatingSystemLocale();
+
+ static Locale captureOperatingSystemLocale() {
+ return Locale.getDefault(Locale.Category.FORMAT);
+ }
+
+ private static RegionalSettings instance;
+
+ private Source source;
+ private Locale customLocale;
+
+ private RegionalSettings() {
+ reload();
+ }
+
+ public static synchronized RegionalSettings getInstance() {
+ if (instance == null) {
+ instance = new RegionalSettings();
+ }
+ return instance;
+ }
+
+ /**
+ * Re-reads the configuration, degrading to {@link Source#OPERATING_SYSTEM}
on anything unusable.
+ */
+ public void reload() {
+ String sourceValue =
+ HopConfig.readOptionString(STRING_REGIONAL_SETTINGS_SOURCE,
Source.OPERATING_SYSTEM.name());
+ try {
+ source = Source.valueOf(sourceValue);
+ } catch (IllegalArgumentException e) {
+ LogChannel.GENERAL.logBasic(
+ "Unknown value '"
+ + sourceValue
+ + "' for option "
+ + STRING_REGIONAL_SETTINGS_SOURCE
+ + ", deriving regional settings from the operating system
instead.");
+ source = Source.OPERATING_SYSTEM;
+ }
+
+ String localeValue =
HopConfig.readOptionString(STRING_REGIONAL_SETTINGS_LOCALE, null);
+ customLocale = Utils.isEmpty(localeValue) ? null :
EnvUtil.createLocale(localeValue);
+
+ if (source == Source.CUSTOM && !isUsable(customLocale)) {
+ LogChannel.GENERAL.logBasic(
+ "Regional settings locale '"
+ + localeValue
+ + "' is not available in this JVM, deriving regional settings
from the operating"
+ + " system instead.");
+ source = Source.OPERATING_SYSTEM;
+ }
+ }
+
+ /** Persists the current source and custom locale. */
+ public void save() {
+ HopConfig.getInstance().saveOption(STRING_REGIONAL_SETTINGS_SOURCE,
source.name());
+ HopConfig.getInstance()
+ .saveOption(
+ STRING_REGIONAL_SETTINGS_LOCALE, customLocale == null ? null :
customLocale.toString());
+ }
+
+ /** The locale actually used to format numbers, currencies and dates. */
+ public Locale getEffectiveLocale() {
+ return switch (source) {
+ case OPERATING_SYSTEM -> OPERATING_SYSTEM_LOCALE;
+ case CUSTOM -> customLocale;
+ case LANGUAGE -> LanguageChoice.getInstance().getDefaultLocale();
+ };
+ }
+
+ /**
+ * Applies the regional settings for a headless run (hop-run, hop-server,
REST), so those runs
+ * honour the configuration of the machine they run on.
+ *
+ * <p>Distributed Beam and Spark workers are not covered by this method:
they never load a {@code
+ * hop-config.json} in the first place, so they fall back to the default
source and format with
+ * their own operating system locale regardless of what this method would
apply.
+ */
+ public void applyHeadless() {
+ // Under the default source this writes OPERATING_SYSTEM_LOCALE, which was
captured from the
+ // JVM's own initial FORMAT default — precisely what a headless run
already carries, including
+ // when it was set with -Duser.language or -Duser.language.format. Writing
it back is therefore
+ // a no-op.
+ Locale formatLocale = getEffectiveLocale();
+ if (formatLocale == null) {
+ LogChannel.GENERAL.logBasic(
+ "No usable regional settings locale is configured; leaving the
format settings alone.");
+ return;
+ }
+ Locale.setDefault(Locale.Category.FORMAT, formatLocale);
+ logEffective(LogChannel.GENERAL, "installation:" + source.name());
+ }
+
+ /**
+ * Applies the interface language and then the regional settings, in that
order.
+ *
+ * <p>The order is mandatory: {@code Locale.setDefault(Locale)} writes all
three categories, so
+ * setting the language after the regional settings would wipe the FORMAT
category. For the same
+ * reason the FORMAT category is always written back, even when the regional
settings are derived
+ * from the language and the two carry the same value.
+ */
+ public void applyGui() {
+ Locale.setDefault(LanguageChoice.getInstance().getDefaultLocale());
+ Locale formatLocale = getEffectiveLocale();
+ if (formatLocale == null) {
+ LogChannel.GENERAL.logBasic(
+ "No usable regional settings locale is configured; leaving the
format settings alone.");
+ return;
+ }
+ Locale.setDefault(Locale.Category.FORMAT, formatLocale);
+ logEffective(LogChannel.GENERAL, "installation:" + source.name());
+ }
+
+ /**
+ * Writes the language, FORMAT locale and default timezone currently in
effect. hop-gui, hop-run
+ * and hop-server all log this so a machine-local mismatch is visible
without inspecting
+ * configuration files.
+ *
+ * @param log channel to write to; {@link LogChannel#GENERAL} when none is
available yet
+ * @param sourceDescription where the FORMAT locale came from, for example
{@code
+ * installation:CUSTOM}
+ */
+ public static void logEffective(ILogChannel log, String sourceDescription) {
+ if (log == null) {
+ return;
+ }
+ log.logBasic(
+ "Regional settings: language="
+ + Locale.getDefault()
+ + " format="
+ + Locale.getDefault(Locale.Category.FORMAT)
+ + " timezone="
+ + TimeZone.getDefault().getID()
+ + " source="
+ + sourceDescription);
+ }
+
+ private static boolean isUsable(Locale locale) {
+ return locale != null &&
Arrays.asList(Locale.getAvailableLocales()).contains(locale);
+ }
+
+ public Source getSource() {
+ return source;
+ }
+
+ public void setSource(Source source) {
+ this.source = source;
+ }
+
+ public Locale getCustomLocale() {
+ return customLocale;
+ }
+
+ public void setCustomLocale(Locale customLocale) {
+ this.customLocale = customLocale;
+ }
+
+ public Locale getOperatingSystemLocale() {
+ return OPERATING_SYSTEM_LOCALE;
+ }
+}
diff --git
a/core/src/main/java/org/apache/hop/i18n/RegionalSettingsPreview.java
b/core/src/main/java/org/apache/hop/i18n/RegionalSettingsPreview.java
new file mode 100644
index 0000000000..77d7923e55
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/i18n/RegionalSettingsPreview.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import java.text.DateFormat;
+import java.text.NumberFormat;
+import java.util.Date;
+import java.util.Locale;
+
+/**
+ * The sample values shown in the regional settings preview, computed for an
arbitrary locale
+ * without touching the JVM default. Deliberately free of any UI dependency so
it can be tested
+ * headlessly.
+ */
+public class RegionalSettingsPreview {
+
+ /** A fixed sample number, chosen to exercise both the grouping and the
decimal separator. */
+ private static final double SAMPLE_NUMBER = 10000.23d;
+
+ private static final double SAMPLE_CURRENCY = 1234.56d;
+ private static final double SAMPLE_PERCENT = 0.85d;
+
+ private final String shortDate;
+ private final String longDate;
+ private final String shortTime;
+ private final String longTime;
+ private final String number;
+ private final String negativeNumber;
+ private final String currency;
+ private final String percent;
+
+ private RegionalSettingsPreview(Locale locale, Date now) {
+ this.shortDate = DateFormat.getDateInstance(DateFormat.SHORT,
locale).format(now);
+ this.longDate = DateFormat.getDateInstance(DateFormat.LONG,
locale).format(now);
+ this.shortTime = DateFormat.getTimeInstance(DateFormat.SHORT,
locale).format(now);
+ this.longTime = DateFormat.getTimeInstance(DateFormat.MEDIUM,
locale).format(now);
+ this.number = NumberFormat.getNumberInstance(locale).format(SAMPLE_NUMBER);
+ this.negativeNumber =
NumberFormat.getNumberInstance(locale).format(-SAMPLE_NUMBER);
+ this.currency =
NumberFormat.getCurrencyInstance(locale).format(SAMPLE_CURRENCY);
+ this.percent =
NumberFormat.getPercentInstance(locale).format(SAMPLE_PERCENT);
+ }
+
+ /** Builds the preview values for the given locale, using the current date
and time. */
+ public static RegionalSettingsPreview of(Locale locale) {
+ return new RegionalSettingsPreview(locale, new Date());
+ }
+
+ /**
+ * Builds the preview values for a fixed instant. Package-private on
purpose: comparing how two
+ * locales render the same instant is only meaningful if it really is the
same instant, and some
+ * calendar dates render identically in two locales that normally differ —
the Italian and US
+ * short formats coincide on 10/10, 11/11 and 12/12, for instance.
+ */
+ static RegionalSettingsPreview of(Locale locale, Date instant) {
+ return new RegionalSettingsPreview(locale, instant);
+ }
+
+ public String getShortDate() {
+ return shortDate;
+ }
+
+ public String getLongDate() {
+ return longDate;
+ }
+
+ public String getShortTime() {
+ return shortTime;
+ }
+
+ public String getLongTime() {
+ return longTime;
+ }
+
+ public String getNumber() {
+ return number;
+ }
+
+ public String getNegativeNumber() {
+ return negativeNumber;
+ }
+
+ public String getCurrency() {
+ return currency;
+ }
+
+ public String getPercent() {
+ return percent;
+ }
+}
diff --git a/core/src/test/java/org/apache/hop/core/ConstLocaleTest.java
b/core/src/test/java/org/apache/hop/core/ConstLocaleTest.java
new file mode 100644
index 0000000000..fae20292a4
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/ConstLocaleTest.java
@@ -0,0 +1,69 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Locale;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class ConstLocaleTest {
+
+ @Test
+ void separatorsFollowTheFormatCategoryAtCallTime() {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.US);
+ assertEquals('.', Const.getDefaultDecimalSeparator());
+ assertEquals(',', Const.getDefaultGroupingSeparator());
+
+ Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+ assertEquals(',', Const.getDefaultDecimalSeparator());
+ assertEquals('.', Const.getDefaultGroupingSeparator());
+ }
+
+ @Test
+ void currencySymbolFollowsTheFormatCategoryAtCallTime() {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.US);
+ assertEquals("$", Const.getDefaultCurrencySymbol());
+
+ Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+ assertEquals("€", Const.getDefaultCurrencySymbol());
+ }
+
+ /**
+ * The symbols are cached because these accessors sit on the ValueMetaBase
construction path.
+ * Repeated reads without a locale change must stay stable, and a locale
change must still be
+ * picked up - that second half is what makes the cache safe rather than
merely fast.
+ */
+ @Test
+ void cachedSymbolsAreStableAcrossRepeatedReadsAndStillFollowALocaleChange() {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+ char first = Const.getDefaultDecimalSeparator();
+ char second = Const.getDefaultDecimalSeparator();
+ assertEquals(first, second);
+ assertEquals(',', first);
+
+ Locale.setDefault(Locale.Category.FORMAT, Locale.US);
+ assertEquals('.', Const.getDefaultDecimalSeparator());
+
+ Locale.setDefault(Locale.Category.FORMAT, Locale.ITALY);
+ assertEquals(',', Const.getDefaultDecimalSeparator());
+ }
+}
diff --git
a/core/src/test/java/org/apache/hop/core/row/value/ValueMetaDateFormatLocaleTest.java
b/core/src/test/java/org/apache/hop/core/row/value/ValueMetaDateFormatLocaleTest.java
new file mode 100644
index 0000000000..f1aecfc838
--- /dev/null
+++
b/core/src/test/java/org/apache/hop/core/row/value/ValueMetaDateFormatLocaleTest.java
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.core.row.value;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.Date;
+import java.util.Locale;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+/**
+ * The interface language and the regional settings are separate since the
FORMAT category was
+ * introduced, so a locale set explicitly on a field has to be compared
against the regional
+ * settings rather than against the language.
+ */
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class ValueMetaDateFormatLocaleTest {
+
+ /** 1 January 2025, so the month name differs per locale. */
+ private static final Date DATE = new Date(1735689600000L);
+
+ private static final String MASK = "MMMM yyyy";
+
+ private static void languageAndRegionalSettings(Locale language, Locale
regional) {
+ // Order matters: setDefault(Locale) writes all three categories, so the
FORMAT one goes last.
+ Locale.setDefault(language);
+ Locale.setDefault(Locale.Category.FORMAT, regional);
+ }
+
+ /**
+ * The regression this test exists for: the locale picked on the field
happens to be the same as
+ * the interface language. Comparing it against the language would classify
it as "no locale
+ * chosen" and fall back to the regional settings, silently discarding the
user's choice.
+ */
+ @Test
+ void explicitDateLocaleIsHonouredEvenWhenItEqualsTheInterfaceLanguage() {
+ languageAndRegionalSettings(Locale.ITALY, Locale.US);
+
+ ValueMetaDate valueMeta = new ValueMetaDate("d");
+ valueMeta.setConversionMask(MASK);
+ valueMeta.setDateFormatLocale(Locale.ITALY);
+
+ assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+ }
+
+ /** The same choice, when it does not collide with the language, always
worked. */
+ @Test
+ void explicitDateLocaleIsHonouredWhenItDiffersFromTheInterfaceLanguage() {
+ languageAndRegionalSettings(Locale.ITALY, Locale.US);
+
+ ValueMetaDate valueMeta = new ValueMetaDate("d");
+ valueMeta.setConversionMask(MASK);
+ valueMeta.setDateFormatLocale(Locale.FRANCE);
+
+ assertEquals("janvier 2025", valueMeta.getDateFormat().format(DATE));
+ }
+
+ /** With no explicit choice the field follows the regional settings, not the
language. */
+ @Test
+ void withoutAnExplicitDateLocaleTheRegionalSettingsWin() {
+ languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+ ValueMetaDate valueMeta = new ValueMetaDate("d");
+ valueMeta.setConversionMask(MASK);
+ valueMeta.setDateFormatLocale(null);
+
+ assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+ }
+
+ /**
+ * A newly constructed field must pick FORMAT as its date locale. Using
DISPLAY (the interface
+ * language) here would format month names in English while numbers followed
Italian separators.
+ */
+ @Test
+ void constructorDateLocaleFollowsTheRegionalSettingsNotTheLanguage() {
+ languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+ ValueMetaDate valueMeta = new ValueMetaDate("d");
+ valueMeta.setConversionMask(MASK);
+
+ assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+ }
+
+ @Test
+ void constructorCollatorLocaleFollowsTheRegionalSettingsNotTheLanguage() {
+ languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+ ValueMetaString valueMeta = new ValueMetaString("s");
+
+ assertEquals(Locale.ITALY, valueMeta.getCollatorLocale());
+ }
+
+ @Test
+ void
timestampExplicitDateLocaleIsHonouredEvenWhenItEqualsTheInterfaceLanguage() {
+ languageAndRegionalSettings(Locale.ITALY, Locale.US);
+
+ ValueMetaTimestamp valueMeta = new ValueMetaTimestamp("t");
+ valueMeta.setConversionMask(MASK);
+ valueMeta.setDateFormatLocale(Locale.ITALY);
+
+ assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+ }
+
+ @Test
+ void timestampWithoutAnExplicitDateLocaleFollowsTheRegionalSettings() {
+ languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+ ValueMetaTimestamp valueMeta = new ValueMetaTimestamp("t");
+ valueMeta.setConversionMask(MASK);
+ valueMeta.setDateFormatLocale(null);
+
+ assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+ }
+
+ @Test
+ void
timestampConstructorDateLocaleFollowsTheRegionalSettingsNotTheLanguage() {
+ languageAndRegionalSettings(Locale.US, Locale.ITALY);
+
+ ValueMetaTimestamp valueMeta = new ValueMetaTimestamp("t");
+ valueMeta.setConversionMask(MASK);
+
+ assertEquals("gennaio 2025", valueMeta.getDateFormat().format(DATE));
+ }
+}
diff --git
a/core/src/test/java/org/apache/hop/i18n/RegionalSettingsApplyTest.java
b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsApplyTest.java
new file mode 100644
index 0000000000..856a908324
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsApplyTest.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.text.DecimalFormatSymbols;
+import java.util.Locale;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.value.ValueMetaNumber;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class RegionalSettingsApplyTest {
+
+ // RestoreHopEnvironmentExtension restores locales and LanguageChoice but
knows nothing about
+ // the RegionalSettings singleton, so reset it explicitly to avoid leaking
state into whatever
+ // test class runs next in the same JVM fork.
+ @AfterAll
+ static void resetRegionalSettingsSingleton() {
+ RegionalSettings.getInstance().reload();
+ }
+
+ @BeforeEach
+ void resetConfiguration() {
+
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE,
null);
+
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_LOCALE,
null);
+
LanguageChoice.getInstance().setDefaultLocale(Locale.forLanguageTag("en-US"));
+ RegionalSettings.getInstance().reload();
+ }
+
+ /**
+ * With no regional configuration at all, a headless run puts the operating
system locale on the
+ * FORMAT category and leaves the interface language alone.
+ *
+ * <p>This is not a behaviour change: the operating system locale is the
JVM's own initial
+ * default, which is exactly what a headless run already carries, so writing
it back changes
+ * nothing that an existing installation observes.
+ */
+ @Test
+ void headlessAppliesTheOperatingSystemLocaleWhenNothingIsConfigured() {
+ Locale.setDefault(Locale.GERMANY);
+ Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
+ RegionalSettings settings = RegionalSettings.getInstance();
+
+ settings.applyHeadless();
+
+ assertEquals(Locale.GERMANY, Locale.getDefault());
+ assertEquals(settings.getOperatingSystemLocale(),
Locale.getDefault(Locale.Category.FORMAT));
+ }
+
+ /**
+ * Windows starts the JVM with the display language as the general locale
and the regional format
+ * in FORMAT. The operating system locale must be read from FORMAT, or a
headless run would
+ * overwrite the regional format with the display language.
+ */
+ @Test
+ void operatingSystemLocaleIsReadFromTheFormatCategory() {
+ Locale.setDefault(Locale.US);
+ Locale.setDefault(Locale.Category.FORMAT, Locale.forLanguageTag("nl-BE"));
+
+ assertEquals(Locale.forLanguageTag("nl-BE"),
RegionalSettings.captureOperatingSystemLocale());
+ }
+
+ @Test
+ void headlessAppliesTheCustomLocaleToTheFormatCategoryOnly() {
+ Locale.setDefault(Locale.GERMANY);
+ RegionalSettings settings = RegionalSettings.getInstance();
+ settings.setSource(RegionalSettings.Source.CUSTOM);
+ settings.setCustomLocale(Locale.ITALY);
+
+ settings.applyHeadless();
+
+ assertEquals(Locale.GERMANY, Locale.getDefault());
+ assertEquals(Locale.ITALY, Locale.getDefault(Locale.Category.FORMAT));
+ }
+
+ /**
+ * With no regional configuration the GUI keeps the interface language while
deriving the formats
+ * from the operating system, exactly as a headless run does.
+ */
+ @Test
+ void guiDerivesFormatsFromTheOperatingSystemByDefault() {
+ Locale.setDefault(Locale.GERMANY);
+ RegionalSettings settings = RegionalSettings.getInstance();
+ Locale osLocale = settings.getOperatingSystemLocale();
+ // Pick a language that cannot coincide with this machine's locale, or the
assertion below
+ // would hold whichever of the two the code actually used.
+ Locale language = Locale.ITALY.equals(osLocale) ? Locale.US : Locale.ITALY;
+ LanguageChoice.getInstance().setDefaultLocale(language);
+
+ settings.applyGui();
+
+ assertEquals(language, Locale.getDefault());
+ assertEquals(osLocale, Locale.getDefault(Locale.Category.FORMAT));
+ assertNotEquals(language, Locale.getDefault(Locale.Category.FORMAT));
+ }
+
+ /**
+ * With the language chosen as the source, every category follows the
interface language, so the
+ * GUI, hop-run and hop-server all agree.
+ */
+ @Test
+ void guiDerivesFormatsFromTheLanguageWhenThatSourceIsChosen() {
+ Locale.setDefault(Locale.GERMANY);
+ RegionalSettings settings = RegionalSettings.getInstance();
+ Locale osLocale = settings.getOperatingSystemLocale();
+ // Pick a language that cannot coincide with this machine's locale, or the
assertion below
+ // would hold whichever of the two the code actually used.
+ Locale language = Locale.ITALY.equals(osLocale) ? Locale.US : Locale.ITALY;
+ LanguageChoice.getInstance().setDefaultLocale(language);
+ settings.setSource(RegionalSettings.Source.LANGUAGE);
+
+ settings.applyGui();
+
+ assertEquals(language, Locale.getDefault());
+ assertEquals(language, Locale.getDefault(Locale.Category.FORMAT));
+ assertNotEquals(osLocale, Locale.getDefault(Locale.Category.FORMAT));
+ }
+
+ /**
+ * The feature this issue asks for: an English interface with Italian
regional settings. The
+ * language governs the messages, the regional locale governs the numbers.
+ */
+ @Test
+ void guiKeepsTheLanguageWhileOverridingTheRegionalSettings() throws
HopValueException {
+
LanguageChoice.getInstance().setDefaultLocale(Locale.forLanguageTag("en-US"));
+ RegionalSettings settings = RegionalSettings.getInstance();
+ settings.setSource(RegionalSettings.Source.CUSTOM);
+ settings.setCustomLocale(Locale.ITALY);
+
+ settings.applyGui();
+
+ // The interface language, which is what ResourceBundle resolves messages
with.
+ assertEquals(Locale.forLanguageTag("en-US"), Locale.getDefault());
+ // The regional settings.
+ assertEquals(Locale.ITALY, Locale.getDefault(Locale.Category.FORMAT));
+ assertEquals(',', new DecimalFormatSymbols().getDecimalSeparator());
+
+ // And the engine follows: the exact literal depends on the default
conversion mask, so we
+ // assert on the separator that actually reaches the converted value.
+ IValueMeta valueMeta = new ValueMetaNumber("n");
+ String converted = valueMeta.getString(10000.23d);
+ assertTrue(converted.contains(","), "Expected an Italian decimal
separator, got: " + converted);
+ }
+}
diff --git
a/core/src/test/java/org/apache/hop/i18n/RegionalSettingsPreviewTest.java
b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsPreviewTest.java
new file mode 100644
index 0000000000..f2028d0a71
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsPreviewTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Calendar;
+import java.util.Date;
+import java.util.Locale;
+import org.junit.jupiter.api.Test;
+
+class RegionalSettingsPreviewTest {
+
+ @Test
+ void formatsNumbersWithTheGivenLocaleRegardlessOfTheJvmDefault() {
+ RegionalSettingsPreview italian = RegionalSettingsPreview.of(Locale.ITALY);
+ RegionalSettingsPreview american = RegionalSettingsPreview.of(Locale.US);
+
+ assertEquals("10.000,23", italian.getNumber());
+ assertEquals("10,000.23", american.getNumber());
+ }
+
+ @Test
+ void formatsNegativeNumbersAndPercentages() {
+ assertEquals("-10.000,23",
RegionalSettingsPreview.of(Locale.ITALY).getNegativeNumber());
+ assertEquals("-10,000.23",
RegionalSettingsPreview.of(Locale.US).getNegativeNumber());
+ // The sample percentage has no fractional part, so it reads the same in
both of the locales
+ // above; assert the exact expected value rather than merely that a
percent sign is present.
+ assertEquals("85%", RegionalSettingsPreview.of(Locale.ITALY).getPercent());
+ }
+
+ @Test
+ void currencyUsesTheLocaleCurrencySymbol() {
+ assertTrue(
+ RegionalSettingsPreview.of(Locale.US).getCurrency().contains("$"),
+ RegionalSettingsPreview.of(Locale.US).getCurrency());
+ assertTrue(
+ RegionalSettingsPreview.of(Locale.ITALY).getCurrency().contains("€"),
+ RegionalSettingsPreview.of(Locale.ITALY).getCurrency());
+ }
+
+ /**
+ * Dates and times must follow the locale that was passed in. Asserting
merely that the strings
+ * are non-empty would pass against an implementation that quietly used the
JVM default locale,
+ * which is the one mistake this class exists to avoid, so two locales
rendering the same instant
+ * must disagree instead.
+ *
+ * <p>The instant is fixed rather than "now" for two reasons. The Italian
short format is {@code
+ * dd/MM/yy} and the US one {@code M/d/yy}, so they render identically
whenever day and month are
+ * equal and both are at least ten — 10/10, 11/11 and 12/12 — and the test
would fail on those
+ * three days a year against perfectly correct code. A fixed instant also
removes the (tiny)
+ * chance of the two preview objects straddling a day boundary and appearing
to differ for the
+ * wrong reason.
+ */
+ @Test
+ void dateAndTimeFollowTheGivenLocale() {
+ Calendar calendar = Calendar.getInstance();
+ calendar.clear();
+ calendar.set(2026, Calendar.JANUARY, 15, 14, 30, 45);
+ Date instant = calendar.getTime();
+
+ RegionalSettingsPreview italian = RegionalSettingsPreview.of(Locale.ITALY,
instant);
+ RegionalSettingsPreview american = RegionalSettingsPreview.of(Locale.US,
instant);
+
+ assertNotEquals(italian.getShortDate(), american.getShortDate());
+ assertNotEquals(italian.getLongDate(), american.getLongDate());
+ assertNotEquals(italian.getShortTime(), american.getShortTime());
+ assertNotEquals(italian.getLongTime(), american.getLongTime());
+ }
+}
diff --git a/core/src/test/java/org/apache/hop/i18n/RegionalSettingsTest.java
b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsTest.java
new file mode 100644
index 0000000000..b834f522bb
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.i18n;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+import java.util.Locale;
+import org.apache.hop.core.config.HopConfig;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class RegionalSettingsTest {
+
+ @BeforeEach
+ void clearConfiguration() {
+
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE,
null);
+
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_LOCALE,
null);
+
LanguageChoice.getInstance().setDefaultLocale(Locale.forLanguageTag("en-US"));
+ RegionalSettings.getInstance().reload();
+ }
+
+ @Test
+ void defaultsToTheOperatingSystemWhenNothingIsConfigured() {
+ RegionalSettings settings = RegionalSettings.getInstance();
+
+ assertEquals(RegionalSettings.Source.OPERATING_SYSTEM,
settings.getSource());
+ assertEquals(settings.getOperatingSystemLocale(),
settings.getEffectiveLocale());
+ }
+
+ @Test
+ void effectiveLocaleFollowsTheOperatingSystemWhenSelected() {
+ RegionalSettings settings = RegionalSettings.getInstance();
+ settings.setSource(RegionalSettings.Source.OPERATING_SYSTEM);
+
+ assertNotNull(settings.getOperatingSystemLocale());
+ assertEquals(settings.getOperatingSystemLocale(),
settings.getEffectiveLocale());
+ }
+
+ @Test
+ void effectiveLocaleIsTheCustomOneWhenOverridden() {
+ RegionalSettings settings = RegionalSettings.getInstance();
+ settings.setSource(RegionalSettings.Source.CUSTOM);
+ settings.setCustomLocale(Locale.ITALY);
+
+ assertEquals(Locale.ITALY, settings.getEffectiveLocale());
+ }
+
+ @Test
+ void unknownSourceValueFallsBackToTheOperatingSystem() {
+ HopConfig.getInstance()
+ .saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE,
"NOT_A_SOURCE");
+ RegionalSettings.getInstance().reload();
+
+ assertEquals(
+ RegionalSettings.Source.OPERATING_SYSTEM,
RegionalSettings.getInstance().getSource());
+ }
+
+ @Test
+ void customSourceWithUnusableLocaleFallsBackToTheOperatingSystem() {
+ HopConfig.getInstance()
+ .saveOption(
+ RegionalSettings.STRING_REGIONAL_SETTINGS_SOURCE,
+ RegionalSettings.Source.CUSTOM.name());
+
HopConfig.getInstance().saveOption(RegionalSettings.STRING_REGIONAL_SETTINGS_LOCALE,
null);
+ RegionalSettings.getInstance().reload();
+
+ RegionalSettings settings = RegionalSettings.getInstance();
+ assertEquals(RegionalSettings.Source.OPERATING_SYSTEM,
settings.getSource());
+ assertEquals(settings.getOperatingSystemLocale(),
settings.getEffectiveLocale());
+ }
+
+ @Test
+ void saveAndReloadRoundTripsTheConfiguration() {
+ RegionalSettings settings = RegionalSettings.getInstance();
+ settings.setSource(RegionalSettings.Source.CUSTOM);
+ settings.setCustomLocale(Locale.ITALY);
+ settings.save();
+
+ settings.reload();
+
+ assertEquals(RegionalSettings.Source.CUSTOM, settings.getSource());
+ assertEquals(Locale.ITALY, settings.getCustomLocale());
+ }
+}
diff --git
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
index 38d8cb6c0f..00eeb65626 100644
---
a/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
+++
b/docs/hop-user-manual/modules/ROOT/pages/hop-gui/perspective-configuration.adoc
@@ -36,6 +36,7 @@ The perspective lists its sections in a tree on the left.
Selecting one shows it
* **General**: how Apache Hop and Hop GUI behave - confirmations, tooltips and
dialog positions.
* **Look & Feel**: how Hop GUI is drawn on your desktop or in your browser.
+* **Regional settings**: the interface language and, separately, the number,
currency and date formats used by Hop GUI, `hop-run` and `hop-server`.
* **Plugins**: one entry per plugin that contributes options, including
xref:hop-gui/perspective-search.adoc[Search], the Explorer perspective and
xref:hop-gui/perspective-ai-advisor.adoc[AI Assistant]. Every option is listed
in <<_plugin_options_reference,Plugin options reference>> below.
* **System variables**: set and inspect the Apache Hop global variables.
* **Security**: how a session is authenticated, split over General, Basic,
External and OAuth sub-tabs. Until authentication is turned on the session is
unrestricted and anyone can change these settings, which the tab says at the
top.
@@ -137,11 +138,6 @@ Some of these settings only take effect after Hop GUI is
restarted; the dialog s
|===
|Option|Description|Default
-|Preferred language
-a|The language Hop GUI is shown in.
-
-TIP: Check the
https://hop.apache.org/community/contribution-guides/translation-contribution-guide/[Translation
Contribution Guide] to translate Apache Hop into your own language.
-|English (US)
|Hide the menu bar|Hide the menu bar. With it hidden, the menu is reachable
from the Apache Hop icon in the upper left corner of Hop GUI.|yes
@@ -303,6 +299,76 @@ Which columns the pipeline metrics grid shows while a
pipeline runs. Hiding a co
|Show Data volume out|Show the *Data volume out* column, bytes written to the
output stream. Needs `HOP_METRIC_DATA_VOLUME` as above.|yes
|===
+=== Regional settings
+
+The `Regional settings` configuration tab controls two things that used to be
tied together: the language of the Hop GUI interface, and the number, currency
and date formats used throughout Hop.
+
+[%header, width=90%, cols="2,5,1"]
+|===
+|Option|Description|Default
+|Preferred Language
+a|the language used for the Hop GUI interface. This does not affect number,
currency or date formats; those are controlled separately below.
+
+TIP: Check the
https://hop.apache.org/community/contribution-guides/translation-contribution-guide/[Translation
Contribution Guide] to translate Apache Hop to your native language.|English
(US)
+
+|Use operating system regional settings
+a|use the number, currency and date formats of the operating system Hop is
running on, regardless of the preferred language. This is the default.
+|yes
+
+|Override regional settings
+a|use an explicitly chosen locale for number, currency and date formats,
regardless of the preferred language. The dropdown lists every locale available
on the platform, not only the languages Hop is translated into, and is only
enabled when this option is ticked.
+|no
+|===
+
+`Use operating system regional settings` and `Override regional settings` are
mutually exclusive: ticking one disables the other. `Use operating system
regional settings` is ticked on a fresh installation, so number, currency and
date formats come from the machine Hop runs on unless you say otherwise.
Unticking both makes the regional settings follow the preferred language, which
is the way to have the interface language and the formats move together.
+
+Two preview panels below the options show sample dates, times, numbers and
currency for the current selection, updated live as options change, before
anything is saved.
+
+Whichever source is in effect, operating system or an explicit override,
applies both to Hop GUI and to headless runs: `hop-run` and `hop-server` read
the same `hop-config.json` and honor it the same way the GUI does.
+
+At start, hop-gui, `hop-run` and `hop-server` log the effective settings, for
example `Regional settings: language=en_US format=it_IT timezone=Europe/Rome
source=installation:CUSTOM`. Use that line to confirm the process you are
looking at is formatting the way you think it is.
+
+NOTE: Because this setting applies to the whole running process, in Hop Web it
belongs to the installation rather than to each connected user. This is already
true of the preferred language today, so it is not a new limitation introduced
by this feature. Saving this tab applies the preferred language to the whole
running process as well, so in Hop Web it affects every connected session until
the server restarts.
+
+==== How regional settings are resolved
+
+The preferred language and the regional settings are two independent settings,
and keeping them apart is the point of this tab. The preferred language governs
the interface: menus, dialogs, button captions and messages. The regional
settings govern the formats data is rendered with: the decimal separator, the
grouping separator, the currency symbol, and the date and time patterns. An
English interface with Italian numbers is a valid and supported combination,
and so is the reverse.
+
+The table below shows which locale ends up driving the formats, for each
source and for each side of Hop.
+
+[%header, width=90%, cols="2,2,2"]
+|===
+|Source |Hop GUI |`hop-run` and `hop-server`
+
+|`Use operating system regional settings` (the default)
+|operating system
+|operating system
+
+|`Override regional settings`
+|the chosen locale
+|the chosen locale
+
+|Neither option ticked
+|preferred language
+|preferred language
+|===
+
+Previously the resolution was not consistent. In Hop GUI the preferred
language decided the formats, except on fields carrying explicit decimal,
grouping or currency symbols, which followed the operating system; `hop-run`
and `hop-server` always used the operating system, so the editor and production
could disagree on the same pipeline. The formats now come from the operating
system everywhere unless an explicit choice is made, and the preferred language
never affects them.
+
+On an existing installation whose configuration is left untouched, nothing
changes for `hop-run` and `hop-server`. In Hop GUI, fields that carry explicit
decimal, grouping or currency symbols are unchanged as well; fields left blank,
and dates with no explicit mask, now follow the operating system where they
previously followed the preferred language.
+
+The reason to untick both options is reproducibility against the preferred
language: it makes Hop GUI, `hop-run` and `hop-server` agree on that language,
so a number converted to a string reads the same in the editor and in
production, and any hash computed over such a string stays stable between them.
+
+Resolution order, most specific first:
+
+. Field-level decimal, grouping, currency, date locale and date timezone on a
transform field. These always win.
+. This installation tab (operating system, an explicit override, or the
preferred language).
+. The JVM / operating system default.
+
+The default source is the operating system. Hop GUI, `hop-run` and
`hop-server` then agree *on the same machine*, but a laptop on `nl_BE` and a
hop-server on `en_US` still format blank fields differently. *Override regional
settings* (and copying that `hop-config.json`) is the way to pin a locale for
an installation. Transform field symbols remain the way to handle a file that
does not match that locale.
+
+Distributed Beam and Spark workers never load `hop-config.json`. They format
with their own JVM locale and timezone unless the pipeline sets field-level
symbols.
+
=== Plugins
The plugins tab contains configuration options that are provided by the
various plugins in Apache Hop.
diff --git a/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
b/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
index 50f7db499f..9498a3d730 100644
--- a/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
+++ b/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
@@ -49,6 +49,7 @@ import org.apache.hop.datastream.plugin.DataStreamPluginType;
import org.apache.hop.execution.plugin.ExecutionInfoLocationPluginType;
import org.apache.hop.execution.sampler.ExecutionDataSamplerPluginType;
import org.apache.hop.hop.plugin.HopCommandPluginType;
+import org.apache.hop.i18n.RegionalSettings;
import org.apache.hop.imp.ImportPluginType;
import org.apache.hop.lineage.hub.LineageHub;
import org.apache.hop.lineage.plugin.LineageSinkPluginType;
@@ -140,6 +141,11 @@ public class HopEnvironment {
HopClientEnvironment.init();
}
+ // Install the regional settings (decimal separators, currency, date
formats) before any
+ // conversion can happen. With the default configuration this is a
no-op.
+ //
+ RegionalSettings.getInstance().applyHeadless();
+
// Register the native types and the plugins for the various plugin
types...
//
pluginTypes.forEach(PluginRegistry::addPluginType);
diff --git
a/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java
b/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java
index 8e85c22a4d..a8c17b3c78 100644
--- a/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java
+++ b/engine/src/main/java/org/apache/hop/core/file/TextFileInputField.java
@@ -96,11 +96,14 @@ public class TextFileInputField implements
ITextFileInputField {
"yyyyMMdd", "ddMMyyyy", "d-M-yyyy", "d/M/yyyy", "d-M-yy", "d/M/yy",
};
+ // These are DecimalFormat pattern strings, not locale-specific rendered
values: the actual
+ // decimal/grouping separators are substituted at format time from
DecimalFormatSymbols, so
+ // the patterns themselves are locale-invariant and safe to keep in a static
initializer.
private static final String[] numberFormats =
new String[] {
"",
"#",
- Const.DEFAULT_NUMBER_FORMAT,
+ Const.getDefaultNumberFormat(),
"0.00",
"0000000000000",
"###,###,###.#######",
diff --git
a/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
b/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
index ce941f67d0..f894c845df 100644
---
a/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
+++
b/engine/src/main/java/org/apache/hop/pipeline/transforms/file/BaseFileField.java
@@ -143,11 +143,14 @@ public class BaseFileField implements ITextFileInputField
{
"d/M/yy",
};
+ // These are DecimalFormat pattern strings, not locale-specific rendered
values: the actual
+ // decimal/grouping separators are substituted at format time from
DecimalFormatSymbols, so
+ // the patterns themselves are locale-invariant and safe to keep in a static
initializer.
protected static final String[] numberFormats =
new String[] {
"",
"#",
- Const.DEFAULT_NUMBER_FORMAT,
+ Const.getDefaultNumberFormat(),
"0.00",
"0000000000000",
"###,###,###.#######",
diff --git
a/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
b/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
index cc61fef571..b274747416 100644
---
a/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
+++
b/plugins/tech/google/src/main/java/org/apache/hop/pipeline/transforms/googlesheets/GoogleSheetsInputField.java
@@ -84,11 +84,14 @@ public class GoogleSheetsInputField /*implements Cloneable,
ITextFileInputField*
"yyyyMMdd", "ddMMyyyy", "d-M-yyyy", "d/M/yyyy", "d-M-yy", "d/M/yy",
};
+ // These are DecimalFormat pattern strings, not locale-specific rendered
values: the actual
+ // decimal/grouping separators are substituted at format time from
DecimalFormatSymbols, so
+ // the patterns themselves are locale-invariant and safe to keep in a static
initializer.
private static final String[] number_formats =
new String[] {
"",
"#",
- Const.DEFAULT_NUMBER_FORMAT,
+ Const.getDefaultNumberFormat(),
"0.00",
"0000000000000",
"###,###,###.#######",
diff --git
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
index 14b3a8ae8d..490fe837ad 100644
---
a/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
+++
b/plugins/transforms/textfile/src/main/java/org/apache/hop/pipeline/transforms/csvinput/CsvInputField.java
@@ -82,11 +82,14 @@ public class CsvInputField implements ITextFileInputField {
"yyyyMMdd", "ddMMyyyy", "d-M-yyyy", "d/M/yyyy", "d-M-yy", "d/M/yy",
};
+ // These are DecimalFormat pattern strings, not locale-specific rendered
values: the actual
+ // decimal/grouping separators are substituted at format time from
DecimalFormatSymbols, so
+ // the patterns themselves are locale-invariant and safe to keep in a static
initializer.
private static final String[] numberFormats =
new String[] {
"",
"#",
- Const.DEFAULT_NUMBER_FORMAT,
+ Const.getDefaultNumberFormat(),
"0.00",
"0000000000000",
"###,###,###.#######",
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
index eb6ca942d1..cf8b84e7dd 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/HopGui.java
@@ -28,7 +28,6 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
-import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -87,7 +86,7 @@ import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.core.vfs.HopVfsNamespace;
import org.apache.hop.core.vfs.HopVfsNamespaces;
import org.apache.hop.i18n.BaseMessages;
-import org.apache.hop.i18n.LanguageChoice;
+import org.apache.hop.i18n.RegionalSettings;
import org.apache.hop.metadata.api.IHasHopMetadataProvider;
import org.apache.hop.metadata.api.IHopMetadataProvider;
import org.apache.hop.metadata.serializer.multi.MultiMetadataProvider;
@@ -676,7 +675,7 @@ public class HopGui
if (!HopLogStore.isInitialized()) {
HopLogStore.init();
}
- Locale.setDefault(LanguageChoice.getInstance().getDefaultLocale());
+ RegionalSettings.getInstance().applyGui();
HopGui hopGui = HopGui.getInstance();
hopGui.getCommandLineArguments().addAll(Arrays.asList(arguments));
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index 8ddb686a72..ea106ad54e 100644
---
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -27,12 +27,9 @@ import org.apache.hop.core.config.HopConfig;
import org.apache.hop.core.gui.plugin.GuiPlugin;
import org.apache.hop.core.gui.plugin.tab.GuiTab;
import org.apache.hop.core.gui.plugin.toolbar.GuiToolbarElement;
-import org.apache.hop.core.util.EnvUtil;
import org.apache.hop.history.AuditManager;
import org.apache.hop.history.AuditState;
import org.apache.hop.i18n.BaseMessages;
-import org.apache.hop.i18n.GlobalMessages;
-import org.apache.hop.i18n.LanguageChoice;
import org.apache.hop.ui.core.PropsUi;
import org.apache.hop.ui.core.dialog.BaseDialog;
import org.apache.hop.ui.core.dialog.ErrorDialog;
@@ -136,7 +133,6 @@ public class ConfigGuiOptionsTab {
private Button wMetricsPanelShowDataVolume;
private Button wMetricsPanelShowDataVolumeIn;
private Button wMetricsPanelShowDataVolumeOut;
- private Combo wDefaultLocale;
private Combo wAutoLayoutDirection;
private Text wAutoLayoutLayerSpacing;
@@ -291,15 +287,6 @@ public class ConfigGuiOptionsTab {
// Reload global zoom
String globalZoomFactor = Integer.toString((int)
(props.getGlobalZoomFactor() * 100)) + '%';
wGlobalZoom.setText(globalZoomFactor);
-
- // Reload default locale
- int idxDefault =
- Const.indexOfString(
- LanguageChoice.getInstance().getDefaultLocale().toString(),
- GlobalMessages.localeCodes);
- if (idxDefault >= 0) {
- wDefaultLocale.select(idxDefault);
- }
} finally {
// Always reset the flag
isReloading = false;
@@ -360,24 +347,6 @@ public class ConfigGuiOptionsTab {
lookScrolledComposite = sLookComp;
lastControl = expandToolbar;
- // Preferred language - at the top
- Control[] defaultLocaleControls =
- createComboField(
- wLookComp,
- "EnterOptionsDialog.DefaultLocale.Label",
- null,
- GlobalMessages.localeDescr,
- lastControl,
- margin);
- wDefaultLocale = (Combo) defaultLocaleControls[1];
- int idxDefault =
- Const.indexOfString(
- LanguageChoice.getInstance().getDefaultLocale().toString(),
GlobalMessages.localeCodes);
- if (idxDefault >= 0) {
- wDefaultLocale.select(idxDefault);
- }
- lastControl = wDefaultLocale;
-
// Hide menu bar - at the top
wHideMenuBar =
createCheckbox(
@@ -1442,15 +1411,6 @@ public class ConfigGuiOptionsTab {
props.setMetricsPanelShowDataVolumeOut(
dataVolumeVarEnabled && wMetricsPanelShowDataVolumeOut.getSelection());
- int defaultLocaleIndex = wDefaultLocale.getSelectionIndex();
- if (defaultLocaleIndex < 0 || defaultLocaleIndex >=
GlobalMessages.localeCodes.length) {
- // Code hardening, when the combo-box ever gets in a strange state,
- // use the first language as default (should be English)
- defaultLocaleIndex = 0;
- }
- String defaultLocale = GlobalMessages.localeCodes[defaultLocaleIndex];
-
LanguageChoice.getInstance().setDefaultLocale(EnvUtil.createLocale(defaultLocale));
-
if (EnvironmentUtils.getInstance().isWeb()) {
// Hop Web: store theme in audit (per-user); followSystem = follow
OS/browser dark mode
boolean followSystem = wWebFollowSystemTheme != null &&
wWebFollowSystemTheme.getSelection();
diff --git
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigRegionalSettingsTab.java
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigRegionalSettingsTab.java
new file mode 100644
index 0000000000..5eb575568b
--- /dev/null
+++
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigRegionalSettingsTab.java
@@ -0,0 +1,532 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+package org.apache.hop.ui.hopgui.perspective.configuration.tabs;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Locale;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.gui.plugin.GuiPlugin;
+import org.apache.hop.core.gui.plugin.tab.GuiTab;
+import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.i18n.GlobalMessages;
+import org.apache.hop.i18n.LanguageChoice;
+import org.apache.hop.i18n.RegionalSettings;
+import org.apache.hop.i18n.RegionalSettingsPreview;
+import org.apache.hop.ui.core.PropsUi;
+import org.apache.hop.ui.core.dialog.BaseDialog;
+import org.apache.hop.ui.core.gui.GuiResource;
+import org.apache.hop.ui.core.widget.ComboFilterPopup;
+import
org.apache.hop.ui.hopgui.perspective.configuration.ConfigurationPerspective;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CTabFolder;
+import org.eclipse.swt.custom.CTabItem;
+import org.eclipse.swt.custom.ScrolledComposite;
+import org.eclipse.swt.layout.FormAttachment;
+import org.eclipse.swt.layout.FormData;
+import org.eclipse.swt.layout.FormLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Control;
+import org.eclipse.swt.widgets.Group;
+import org.eclipse.swt.widgets.Label;
+
+/**
+ * Lets the user pick the interface language and, independently of it, the
locale that decides the
+ * decimal separator, the grouping separator, the currency and the date and
time formats.
+ */
+@GuiPlugin
+public class ConfigRegionalSettingsTab {
+ private static final Class<?> PKG = BaseDialog.class;
+
+ private Combo wDefaultLocale;
+ private Button wUseOperatingSystem;
+ private Button wOverride;
+ private Combo wLocale;
+
+ private Label wShortDate;
+ private Label wLongDate;
+ private Label wShortTime;
+ private Label wLongTime;
+ private Label wNumber;
+ private Label wNegativeNumber;
+ private Label wCurrency;
+ private Label wPercent;
+
+ private List<Locale> localeList;
+
+ /** Widgets fire their listeners while they are being populated; don't save
on those events. */
+ private boolean isInitializing;
+
+ public ConfigRegionalSettingsTab() {
+ // This instance is created in the GuiPlugin system by calling this
constructor, after which it
+ // calls the addConfigRegionalSettingsTab() method.
+ }
+
+ @GuiTab(
+ id = "10120-config-perspective-regional-settings-tab",
+ parentId = ConfigurationPerspective.CONFIG_PERSPECTIVE_TABS,
+ description = "Regional settings")
+ public void addConfigRegionalSettingsTab(CTabFolder wTabFolder) {
+ isInitializing = true;
+
+ int margin = PropsUi.getMargin();
+ RegionalSettings settings = RegionalSettings.getInstance();
+ // Mutable: a saved locale that the offered list filters out is appended
to it.
+ localeList = new ArrayList<>(buildLocaleList());
+
+ CTabItem wRegionalTab = new CTabItem(wTabFolder, SWT.NONE);
+ wRegionalTab.setFont(GuiResource.getInstance().getFontDefault());
+ wRegionalTab.setText(BaseMessages.getString(PKG,
"ConfigRegionalSettingsTab.Title"));
+ wRegionalTab.setImage(GuiResource.getInstance().getImageOptions());
+
+ ScrolledComposite sRegionalComp =
+ new ScrolledComposite(wTabFolder, SWT.V_SCROLL | SWT.H_SCROLL);
+ sRegionalComp.setLayout(new FormLayout());
+
+ Composite wRegionalComp = new Composite(sRegionalComp, SWT.NONE);
+ PropsUi.setLook(wRegionalComp);
+ FormLayout regionalLayout = new FormLayout();
+ regionalLayout.marginWidth = PropsUi.getFormMargin();
+ regionalLayout.marginHeight = PropsUi.getFormMargin();
+ wRegionalComp.setLayout(regionalLayout);
+
+ Control lastControl = null;
+
+ // Preferred language: this is the interface language only, it no longer
decides the formats.
+ //
+ wDefaultLocale =
+ createComboField(
+ wRegionalComp,
+ "EnterOptionsDialog.DefaultLocale.Label",
+ GlobalMessages.localeDescr,
+ false,
+ lastControl,
+ margin);
+ int languageIndex =
+ Const.indexOfString(
+ LanguageChoice.getInstance().getDefaultLocale().toString(),
GlobalMessages.localeCodes);
+ if (languageIndex >= 0) {
+ wDefaultLocale.select(languageIndex);
+ }
+ lastControl = wDefaultLocale;
+
+ // Use the operating system regional settings.
+ //
+ wUseOperatingSystem =
+ createCheckbox(
+ wRegionalComp,
+ "ConfigRegionalSettingsTab.UseOperatingSystem.Label",
+ "ConfigRegionalSettingsTab.UseOperatingSystem.Tooltip",
+ settings.getSource() == RegionalSettings.Source.OPERATING_SYSTEM,
+ lastControl,
+ margin);
+ wUseOperatingSystem.addListener(
+ SWT.Selection,
+ e -> {
+ if (wUseOperatingSystem.getSelection()) {
+ wOverride.setSelection(false);
+ }
+ onSourceChanged();
+ });
+ lastControl = wUseOperatingSystem;
+
+ // Override the regional settings with an explicitly picked locale.
+ //
+ wOverride =
+ createCheckbox(
+ wRegionalComp,
+ "ConfigRegionalSettingsTab.Override.Label",
+ "ConfigRegionalSettingsTab.Override.Tooltip",
+ settings.getSource() == RegionalSettings.Source.CUSTOM,
+ lastControl,
+ margin);
+ wOverride.addListener(
+ SWT.Selection,
+ e -> {
+ if (wOverride.getSelection()) {
+ wUseOperatingSystem.setSelection(false);
+ // Without a selection the override would be saved as a plain
language choice, leaving
+ // the ticked checkbox and the configuration disagreeing.
+ ensureLocaleSelected();
+ }
+ onSourceChanged();
+ });
+ lastControl = wOverride;
+
+ // Editable, unlike the language combo: the filter popup narrows several
hundred entries by
+ // typing, and it needs a combo it can write into.
+ wLocale =
+ createComboField(
+ wRegionalComp,
+ "ConfigRegionalSettingsTab.Locale.Label",
+
localeList.stream().map(Locale::getDisplayName).toArray(String[]::new),
+ true,
+ lastControl,
+ margin);
+ ComboFilterPopup.attach(wLocale, () -> Arrays.asList(wLocale.getItems()),
null);
+ selectCustomLocale(settings.getCustomLocale());
+ wLocale.setEnabled(wOverride.getSelection());
+ wLocale.addListener(
+ SWT.Modify,
+ e -> {
+ // Every keystroke lands here now. Half-typed text names no locale,
so wait until the
+ // combo shows one of the offered entries.
+ if (selectedLocaleIndex() < 0) {
+ return;
+ }
+ refreshPreview();
+ save();
+ });
+ lastControl = wLocale;
+
+ // The preview of what the settings above produce, before anything is
saved.
+ //
+ Group wDateTimeGroup =
+ createPreviewGroup(
+ wRegionalComp, "ConfigRegionalSettingsTab.DateTimeGroup.Label",
lastControl, margin);
+ wShortDate =
+ createPreviewRow(wDateTimeGroup,
"ConfigRegionalSettingsTab.ShortDate.Label", null, margin);
+ wLongDate =
+ createPreviewRow(
+ wDateTimeGroup, "ConfigRegionalSettingsTab.LongDate.Label",
wShortDate, margin);
+ wShortTime =
+ createPreviewRow(
+ wDateTimeGroup, "ConfigRegionalSettingsTab.ShortTime.Label",
wLongDate, margin);
+ wLongTime =
+ createPreviewRow(
+ wDateTimeGroup, "ConfigRegionalSettingsTab.LongTime.Label",
wShortTime, margin);
+ lastControl = wDateTimeGroup;
+
+ Group wNumbersGroup =
+ createPreviewGroup(
+ wRegionalComp, "ConfigRegionalSettingsTab.NumbersGroup.Label",
lastControl, margin);
+ wNumber =
+ createPreviewRow(wNumbersGroup,
"ConfigRegionalSettingsTab.Number.Label", null, margin);
+ wNegativeNumber =
+ createPreviewRow(
+ wNumbersGroup, "ConfigRegionalSettingsTab.NegativeNumber.Label",
wNumber, margin);
+ wCurrency =
+ createPreviewRow(
+ wNumbersGroup, "ConfigRegionalSettingsTab.Currency.Label",
wNegativeNumber, margin);
+ wPercent =
+ createPreviewRow(
+ wNumbersGroup, "ConfigRegionalSettingsTab.Percent.Label",
wCurrency, margin);
+
+ // Registered last: the preview it triggers reads every other widget on
this tab.
+ //
+ wDefaultLocale.addListener(
+ SWT.Modify,
+ e -> {
+ refreshPreview();
+ save();
+ });
+
+ refreshPreview();
+
+ wRegionalComp.layout();
+ wRegionalComp.pack();
+ sRegionalComp.setContent(wRegionalComp);
+ sRegionalComp.setExpandHorizontal(true);
+ sRegionalComp.setExpandVertical(true);
+ sRegionalComp.setMinWidth(wRegionalComp.getBounds().width);
+ sRegionalComp.setMinHeight(wRegionalComp.getBounds().height);
+
+ wRegionalTab.setControl(sRegionalComp);
+
+ isInitializing = false;
+ }
+
+ /**
+ * The locales offered for the override. Deliberately not {@link
GlobalMessages#localeCodes},
+ * which only lists the languages Hop is translated into: the regional
settings are independent of
+ * the interface language, so any locale the JVM knows about is a valid
choice. Locales without a
+ * country carry no formats worth picking, and variants and scripts would
only clutter the list
+ * with near-duplicates.
+ */
+ private List<Locale> buildLocaleList() {
+ return Arrays.stream(Locale.getAvailableLocales())
+ .filter(l -> !l.getCountry().isEmpty())
+ .filter(l -> l.getVariant().isEmpty() && l.getScript().isEmpty())
+ .sorted(Comparator.comparing(Locale::getDisplayName))
+ .toList();
+ }
+
+ private void selectCustomLocale(Locale customLocale) {
+ if (customLocale == null) {
+ return;
+ }
+ if (!localeList.contains(customLocale)) {
+ // A saved locale can carry a variant or a script, which the offered
list filters out. Keep it
+ // rather than dropping the user's choice on the floor.
+ localeList.add(customLocale);
+ wLocale.add(customLocale.getDisplayName());
+ }
+ selectLocaleAt(localeList.indexOf(customLocale));
+ }
+
+ /**
+ * The index in {@link #localeList} of the locale the combo currently shows,
or -1 when it shows
+ * none. Resolved through the displayed text rather than {@link
Combo#getSelectionIndex()}: the
+ * filter popup applies a choice with {@code setText()}, which leaves the
selection index behind
+ * on whatever was picked before. Reading the text covers both ways of
choosing, and reports "no
+ * locale" while a query is being typed.
+ */
+ private int selectedLocaleIndex() {
+ return wLocale.indexOf(wLocale.getText());
+ }
+
+ /**
+ * Show the locale at {@code index}. The text is written explicitly on top
of the selection: on an
+ * editable combo {@code select()} alone is not guaranteed to update the
text field, and the text
+ * is what {@link #selectedLocaleIndex()} reads back.
+ */
+ private void selectLocaleAt(int index) {
+ wLocale.select(index);
+ wLocale.setText(wLocale.getItem(index));
+ }
+
+ private void ensureLocaleSelected() {
+ if (selectedLocaleIndex() >= 0) {
+ return;
+ }
+ // Start from the locale that is in effect right now, so ticking the
override keeps the formats
+ // the user is already seeing instead of silently switching them to
another locale.
+ Locale current = RegionalSettings.getInstance().getEffectiveLocale();
+ int index = current == null ? -1 : localeList.indexOf(current);
+ selectLocaleAt(index >= 0 ? index : 0);
+ }
+
+ /**
+ * Reloads the widgets from the configuration, so a change made outside this
tab doesn't leave it
+ * showing stale values. Invoked reflectively when the configuration
perspective is activated.
+ */
+ public void reloadValues() {
+ if (wDefaultLocale == null || wDefaultLocale.isDisposed()) {
+ // The tab was never built or is already disposed.
+ return;
+ }
+
+ isInitializing = true;
+ try {
+ RegionalSettings settings = RegionalSettings.getInstance();
+
+ int languageIndex =
+ Const.indexOfString(
+ LanguageChoice.getInstance().getDefaultLocale().toString(),
+ GlobalMessages.localeCodes);
+ if (languageIndex >= 0) {
+ wDefaultLocale.select(languageIndex);
+ }
+
+ wUseOperatingSystem.setSelection(
+ settings.getSource() == RegionalSettings.Source.OPERATING_SYSTEM);
+ wOverride.setSelection(settings.getSource() ==
RegionalSettings.Source.CUSTOM);
+ selectCustomLocale(settings.getCustomLocale());
+ wLocale.setEnabled(wOverride.getSelection());
+
+ refreshPreview();
+ } finally {
+ isInitializing = false;
+ }
+ }
+
+ private void onSourceChanged() {
+ wLocale.setEnabled(wOverride.getSelection());
+ refreshPreview();
+ save();
+ }
+
+ /**
+ * The locale the current state of the widgets would produce, so the preview
can show the effect
+ * of a choice before it is saved.
+ */
+ private Locale currentEffectiveLocale() {
+ if (wUseOperatingSystem.getSelection()) {
+ return RegionalSettings.getInstance().getOperatingSystemLocale();
+ }
+ if (wOverride.getSelection() && selectedLocaleIndex() >= 0) {
+ return localeList.get(selectedLocaleIndex());
+ }
+ int index = wDefaultLocale.getSelectionIndex();
+ if (index < 0 || index >= GlobalMessages.localeCodes.length) {
+ index = 0;
+ }
+ return EnvUtil.createLocale(GlobalMessages.localeCodes[index]);
+ }
+
+ private void refreshPreview() {
+ RegionalSettingsPreview preview =
RegionalSettingsPreview.of(currentEffectiveLocale());
+ wShortDate.setText(preview.getShortDate());
+ wLongDate.setText(preview.getLongDate());
+ wShortTime.setText(preview.getShortTime());
+ wLongTime.setText(preview.getLongTime());
+ wNumber.setText(preview.getNumber());
+ wNegativeNumber.setText(preview.getNegativeNumber());
+ wCurrency.setText(preview.getCurrency());
+ wPercent.setText(preview.getPercent());
+ }
+
+ private void save() {
+ if (isInitializing) {
+ return;
+ }
+
+ int index = wDefaultLocale.getSelectionIndex();
+ if (index < 0 || index >= GlobalMessages.localeCodes.length) {
+ // Code hardening, when the combo-box ever gets in a strange state,
+ // use the first language as default (should be English)
+ index = 0;
+ }
+ LanguageChoice.getInstance()
+
.setDefaultLocale(EnvUtil.createLocale(GlobalMessages.localeCodes[index]));
+
+ RegionalSettings settings = RegionalSettings.getInstance();
+ if (wUseOperatingSystem.getSelection()) {
+ settings.setSource(RegionalSettings.Source.OPERATING_SYSTEM);
+ } else if (wOverride.getSelection() && selectedLocaleIndex() >= 0) {
+ settings.setSource(RegionalSettings.Source.CUSTOM);
+ settings.setCustomLocale(localeList.get(selectedLocaleIndex()));
+ } else {
+ settings.setSource(RegionalSettings.Source.LANGUAGE);
+ }
+ settings.save();
+ settings.applyGui();
+ }
+
+ private Combo createComboField(
+ Composite parent,
+ String labelKey,
+ String[] items,
+ boolean editable,
+ Control lastControl,
+ int margin) {
+ Label label = new Label(parent, SWT.LEFT);
+ PropsUi.setLook(label);
+ label.setText(BaseMessages.getString(PKG, labelKey));
+
+ FormData fdLabel = new FormData();
+ fdLabel.left = new FormAttachment(0, 0);
+ fdLabel.right = new FormAttachment(100, 0);
+ if (lastControl != null) {
+ fdLabel.top = new FormAttachment(lastControl, margin);
+ } else {
+ fdLabel.top = new FormAttachment(0, margin);
+ }
+ label.setLayoutData(fdLabel);
+
+ Combo combo =
+ new Combo(
+ parent, SWT.SINGLE | SWT.LEFT | SWT.BORDER | (editable ? SWT.NONE
: SWT.READ_ONLY));
+ PropsUi.setLook(combo);
+ combo.setItems(items);
+
+ FormData fdCombo = new FormData();
+ fdCombo.left = new FormAttachment(0, 0);
+ fdCombo.right = new FormAttachment(100, 0);
+ fdCombo.top = new FormAttachment(label, margin / 2);
+ combo.setLayoutData(fdCombo);
+
+ return combo;
+ }
+
+ private Button createCheckbox(
+ Composite parent,
+ String labelKey,
+ String tooltipKey,
+ boolean selected,
+ Control lastControl,
+ int margin) {
+ Button checkbox = new Button(parent, SWT.CHECK);
+ PropsUi.setLook(checkbox);
+ checkbox.setText(BaseMessages.getString(PKG, labelKey));
+ if (tooltipKey != null) {
+ checkbox.setToolTipText(BaseMessages.getString(PKG, tooltipKey));
+ }
+ checkbox.setSelection(selected);
+
+ FormData fdCheckbox = new FormData();
+ fdCheckbox.left = new FormAttachment(0, 0);
+ fdCheckbox.right = new FormAttachment(100, 0);
+ if (lastControl != null) {
+ fdCheckbox.top = new FormAttachment(lastControl, margin);
+ } else {
+ fdCheckbox.top = new FormAttachment(0, margin);
+ }
+ checkbox.setLayoutData(fdCheckbox);
+
+ return checkbox;
+ }
+
+ private Group createPreviewGroup(
+ Composite parent, String labelKey, Control lastControl, int margin) {
+ Group group = new Group(parent, SWT.SHADOW_NONE);
+ PropsUi.setLook(group);
+ group.setText(BaseMessages.getString(PKG, labelKey));
+
+ FormLayout groupLayout = new FormLayout();
+ groupLayout.marginWidth = PropsUi.getFormMargin();
+ groupLayout.marginHeight = PropsUi.getFormMargin();
+ group.setLayout(groupLayout);
+
+ FormData fdGroup = new FormData();
+ fdGroup.left = new FormAttachment(0, 0);
+ fdGroup.right = new FormAttachment(100, 0);
+ if (lastControl != null) {
+ fdGroup.top = new FormAttachment(lastControl, 2 * margin);
+ } else {
+ fdGroup.top = new FormAttachment(0, margin);
+ }
+ group.setLayoutData(fdGroup);
+
+ return group;
+ }
+
+ /** Adds a caption and its value to a preview group and returns the label
carrying the value. */
+ private Label createPreviewRow(Group group, String labelKey, Control
lastControl, int margin) {
+ Label caption = new Label(group, SWT.RIGHT);
+ PropsUi.setLook(caption);
+ caption.setText(BaseMessages.getString(PKG, labelKey));
+
+ FormData fdCaption = new FormData();
+ fdCaption.left = new FormAttachment(0, 0);
+ fdCaption.right = new FormAttachment(PropsUi.getInstance().getMiddlePct(),
-margin);
+ if (lastControl != null) {
+ fdCaption.top = new FormAttachment(lastControl, margin);
+ } else {
+ fdCaption.top = new FormAttachment(0, margin);
+ }
+ caption.setLayoutData(fdCaption);
+
+ Label value = new Label(group, SWT.LEFT);
+ PropsUi.setLook(value);
+
+ FormData fdValue = new FormData();
+ fdValue.left = new FormAttachment(PropsUi.getInstance().getMiddlePct(), 0);
+ fdValue.right = new FormAttachment(100, 0);
+ fdValue.top = new FormAttachment(caption, 0, SWT.CENTER);
+ value.setLayoutData(fdValue);
+
+ return value;
+ }
+}
diff --git
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index f0c1197396..106430f850 100644
---
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -447,3 +447,19 @@ TransformFieldsDialog.TableCol.StorageType=Storage
TransformFieldsDialog.TableCol.TrimType=Trim
TransformFieldsDialog.TableCol.Type=Type
TransformFieldsDialog.Title=Transform fields and their origin
+ConfigRegionalSettingsTab.Title=Regional settings
+ConfigRegionalSettingsTab.UseOperatingSystem.Label=Use operating system
regional settings
+ConfigRegionalSettingsTab.UseOperatingSystem.Tooltip=Inherit decimal
separator, grouping separator, currency and date formats from the operating
system Hop runs on.\nThis is the default: unless a choice below overrides it,
those formats come from the machine Hop runs on, in the Hop GUI and in hop-run
and hop-server alike, whatever the interface language is.
+ConfigRegionalSettingsTab.Override.Label=Override regional settings
+ConfigRegionalSettingsTab.Override.Tooltip=Pick the locale used for decimal
separator, grouping separator, currency and date formats, independently of the
interface language.
+ConfigRegionalSettingsTab.Locale.Label=Locale:
+ConfigRegionalSettingsTab.DateTimeGroup.Label=Date and time
+ConfigRegionalSettingsTab.NumbersGroup.Label=Numbers and currency
+ConfigRegionalSettingsTab.ShortDate.Label=Short date:
+ConfigRegionalSettingsTab.LongDate.Label=Long date:
+ConfigRegionalSettingsTab.ShortTime.Label=Short time:
+ConfigRegionalSettingsTab.LongTime.Label=Long time:
+ConfigRegionalSettingsTab.Number.Label=Number:
+ConfigRegionalSettingsTab.NegativeNumber.Label=Negative:
+ConfigRegionalSettingsTab.Currency.Label=Currency:
+ConfigRegionalSettingsTab.Percent.Label=Percent: