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 ced122d1311..547e19cc27f 100644
--- a/core/src/main/java/org/apache/hop/core/Condition.java
+++ b/core/src/main/java/org/apache/hop/core/Condition.java
@@ -847,7 +847,7 @@ public IValueMeta createValueMeta() throws HopPluginException {
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 497ee0a1bf1..0131f59a2c9 100644
--- a/core/src/main/java/org/apache/hop/core/Const.java
+++ b/core/src/main/java/org/apache/hop/core/Const.java
@@ -280,22 +280,100 @@ public String getMessage() {
/** 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();
+ /**
+ * Cached symbols for the regional locale they were built from.
+ *
+ *
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
+ * symbols are therefore cached and rebuilt only when the FORMAT locale actually changes.
+ *
+ *
Both fields are written together under {@code synchronized} and read together, so a racing
+ * reader can never pair one locale's symbols with another locale's marker.
+ */
+ private static DecimalFormatSymbols cachedFormatSymbols;
+
+ private static Locale cachedFormatSymbolsLocale;
+
+ private static synchronized DecimalFormatSymbols getFormatSymbols() {
+ Locale formatLocale = Locale.getDefault(Locale.Category.FORMAT);
+ if (cachedFormatSymbols == null || !formatLocale.equals(cachedFormatSymbolsLocale)) {
+ cachedFormatSymbols = new DecimalFormatSymbols(formatLocale);
+ cachedFormatSymbolsLocale = formatLocale;
+ }
+ return cachedFormatSymbols;
+ }
+
+ /**
+ * The decimal separator of the active regional settings, read at call time.
+ *
+ *
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 getFormatSymbols().getDecimalSeparator();
+ }
+
+ /** The grouping separator of the active regional settings, read at call time. */
+ public static char getDefaultGroupingSeparator() {
+ return getFormatSymbols().getGroupingSeparator();
+ }
+
+ /** The currency symbol of the active regional settings, read at call time. */
+ public static String getDefaultCurrencySymbol() {
+ return getFormatSymbols().getCurrencySymbol();
+ }
+
+ /**
+ * 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), so callers do not generally need to re-read it when the locale changes.
+ */
+ public static String getDefaultNumberFormat() {
+ return ((DecimalFormat) NumberFormat.getInstance(Locale.getDefault(Locale.Category.FORMAT)))
+ .toPattern();
+ }
+
/** Default string representing Null String values (empty) */
public static final String NULL_STRING = "";
@@ -838,6 +916,25 @@ public static boolean toBoolean(String string) {
public static final String HOP_AGGREGATION_ALL_NULLS_ARE_ZERO =
"HOP_AGGREGATION_ALL_NULLS_ARE_ZERO";
+ /**
+ * The FORMAT locale currently in effect (language_COUNTRY, for example {@code en_US} or {@code
+ * nl_BE}). Set when a lifecycle environment is enabled so pipelines can see which regional
+ * settings they are running under.
+ */
+ @Variable(
+ description =
+ "The FORMAT locale in effect for number, currency and date conversion (for example en_US). Set automatically when a lifecycle environment with a format locale is enabled.")
+ public static final String HOP_FORMAT_LOCALE = "HOP_FORMAT_LOCALE";
+
+ /**
+ * The default timezone currently in effect (IANA id, for example {@code Europe/Brussels}). Set
+ * when a lifecycle environment is enabled.
+ */
+ @Variable(
+ description =
+ "The default timezone in effect for date and timestamp conversion (IANA id, for example Europe/Brussels). Set automatically when a lifecycle environment with a timezone is enabled.")
+ public static final String HOP_TIMEZONE = "HOP_TIMEZONE";
+
/** The name of the variable containing an alternative default timestamp format */
@Variable(
description = "The name of the variable containing an alternative default timestamp format")
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 53df16d1d89..b9cf0ef6285 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,10 +313,13 @@ protected ValueMetaBase(
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();
this.collator = Collator.getInstance(this.collatorLocale);
@@ -1296,7 +1299,13 @@ private synchronized SimpleDateFormat getDateFormat(int valueMetaType) {
// 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 18e7b8a6c57..bd5f556d9b7 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 @@ private synchronized SimpleDateFormat getDateFormat(int valueMetaType) {
// 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 00000000000..f178ab1f0cb
--- /dev/null
+++ b/core/src/main/java/org/apache/hop/i18n/RegionalSettings.java
@@ -0,0 +1,216 @@
+/*
+ * 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}.
+ *
+ *
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.
+ */
+ private static final Locale OPERATING_SYSTEM_LOCALE = Locale.getDefault();
+
+ 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.
+ *
+ *
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 default — precisely what a headless run already carries, including when it
+ // was set with -Duser.language. Writing it back is therefore a no-op in practice.
+ 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.
+ *
+ *
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 00000000000..77d7923e557
--- /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 00000000000..fae20292a43
--- /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 00000000000..3e0acb33411
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/row/value/ValueMetaDateFormatLocaleTest.java
@@ -0,0 +1,132 @@
+/*
+ * 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 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 00000000000..c179e8bd16f
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/i18n/RegionalSettingsApplyTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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.
+ *
+ *
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));
+ }
+
+ @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 00000000000..f2028d0a711
--- /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.
+ *
+ *
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 00000000000..b834f522bb3
--- /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/assets/images/hop-gui/environment/environment-dialog-regional-tab.png b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/environment/environment-dialog-regional-tab.png
new file mode 100644
index 00000000000..ff55cd70c3d
Binary files /dev/null and b/docs/hop-user-manual/modules/ROOT/assets/images/hop-gui/environment/environment-dialog-regional-tab.png differ
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 b9c2504fe62..cf59286fe3c 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
@@ -15,6 +15,8 @@ specific language governing permissions and limitations
under the License.
////
:imagesdir: ../assets/images
+:openvar: ${
+:closevar: }
= Configuration Perspective
@@ -32,6 +34,7 @@ The configuration perspective groups configuration options in the following tabs
* **General** options control the behavior of Apache Hop and Hop Gui.
* **Look & Feel** control how Hop Gui looks like on your desktop or in your browser.
+* **Regional settings** control the interface language and, independently, the number, currency and date formats used by Hop GUI, `hop-run` and `hop-server`.
* **Plugin** options provide configuration options to control the behavior of your available Apache Hop plugins (including Explorer and xref:hop-gui/perspective-search.adoc[Search] limits).
* **System variables** allows you to set and configure Apache Hop global variables.
@@ -113,11 +116,82 @@ The `Look & Feel` configuration options allow you to configure various aspects o
|Hide the menu bar|Do not show the menu bar. If enabled (default), the menu options are available from the Apache Hop icon in Hop GUI's upper left corner. |yes
|Show a toolbar above tables|Show a toolbar with options to cut/copy/paste, move lines up/down, navigate to a column, manage xref:pipeline/run-preview-debug-pipeline.adoc#table-views[table views] etc in table grids (e.g. in preview dialogs, transform configuration options)|yes
|Dark mode|use dark mode. On Hop Web and Windows you can toggle this; on macOS and other desktop platforms it follows the operating system theme. Changing it in Hop Web reloads the page to apply the theme. |N/A
+|===
+
+=== 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 default language to use in Hop GUI.
+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.
+. The active xref:projects/projects-environments.adoc[lifecycle environment] format locale and timezone, when set.
+. This installation tab (operating system, an explicit override, or the preferred language).
+. The JVM / operating system default.
+
+The default source on this tab 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 unless the lifecycle environment pins a locale (or you use *Override regional settings* and copy that `hop-config.json`). Transform field symbols remain the way to handle a file that does not match that locale.
+
+When an environment is enabled, Hop publishes the effective values as `{openvar}HOP_FORMAT_LOCALE{closevar}` and `{openvar}HOP_TIMEZONE{closevar}` and the start log line's `source=` shows `environment:name` instead of `installation:…`.
+
+Distributed Beam and Spark workers never load `hop-config.json` or the lifecycle environment. They format with their own JVM locale and timezone unless the pipeline sets field-level symbols.
+
+A hop-server JVM is expected to run one default environment for these conversion defaults. Mixing environments with different FORMAT locales in one process is not supported: `Locale.setDefault` and `TimeZone.setDefault` are process-wide. Field-level overrides still work per pipeline.
=== Plugins
diff --git a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
index 9f79aa2dc20..72a9009c9d2 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/projects/projects-environments.adoc
@@ -117,7 +117,7 @@ Click it to open the environment menu, then choose *Add environment...*.
image:hop-gui/environment/environment-add.png[Add environment menu]
This opens the environment properties dialog.
-The fields are grouped on tabs: *General*, *Configuration files*, and when the matching plugins are installed *Marketplace plugins* and *System resources*.
+The fields are grouped on tabs: *General*, *Regional*, *Configuration files*, and when the matching plugins are installed *Marketplace plugins* and *System resources*.
image:hop-gui/environment/environment-dialog-general-tab.png[Environment Properties General tab,width="90%"]
@@ -139,6 +139,26 @@ image:hop-gui/environment/environment-dialog-general-tab.png[Environment Propert
|Canvas text|Large text drawn in the top-right of pipeline and workflow canvases when this environment is active|Yes|No|
|===
+image:hop-gui/environment/environment-dialog-regional-tab.png[Environment Properties Regional tab,width="90%"]
+
+.Environment Properties -- Regional
+[id="tab-env-props-regional",cols="20%,45%,5%,5%,25%",options="header"]
+|===
+|Property|Description|Variables Supported|Mandatory|Default
+|Format locale a|Locale used for decimal separator, grouping separator, currency and date formats when this environment is enabled in Hop GUI, `hop-run` and `hop-server`. Independent of the Hop GUI language.
+
+Leave *Inherit installation / OS* to use the xref:hop-gui/perspective-configuration.adoc#_regional_settings[Configuration perspective Regional settings]. Field-level decimal, grouping and date locale options still override this default. The effective value is published as `'{openvar}HOP_FORMAT_LOCALE{closevar}'`.
+|No|No|Inherit installation / OS
+|Timezone a|IANA timezone used as the default for date and timestamp conversion (for example `Europe/Brussels`).
+
+Leave *Inherit installation / OS* to keep the JVM default. Field-level date timezones still override this default. The effective value is published as `'{openvar}HOP_TIMEZONE{closevar}'`.
+|No|No|Inherit installation / OS
+|===
+
+A preview under the two combos shows a sample number, a long date and the selected timezone, updated as you change the values.
+
+Pinning locale and timezone on the environment is how a pipeline that omits field-level decimal and grouping symbols produces the same result on a laptop and on a hop-server. Without it, those defaults follow the machine, which is the classic "it works on my machine" failure.
+
image:hop-gui/environment/environment-dialog-configuration-files-tab.png[Environment Properties Configuration files tab,width="90%"]
.Environment Properties -- Configuration files
diff --git a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
index 9d977559132..7ee61a9cc12 100644
--- a/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
+++ b/docs/hop-user-manual/modules/ROOT/pages/variables.adoc
@@ -251,6 +251,7 @@ It defines the max number of simultaneously open files within the transform.
The transform will close/reopen files as necessary to insure the max is not exceeded
|HOP_FILE_OUTPUT_MAX_STREAM_LIFE|0|This project variable is used by the Text File Output transform.
It defines the max number of milliseconds between flushes of files opened by the transform.
+|HOP_FORMAT_LOCALE||The FORMAT locale in effect for number, currency and date conversion (for example `en_US`). Set automatically when a lifecycle environment with a format locale is enabled. See xref:projects/projects-environments.adoc[Projects and Environments].
|HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT|N|Set this variable to N to preserve global log variables defined in pipeline / workflow Properties -> Log panel.
Changing it to true will clear it when export pipeline / workflow.
|HOP_JSON_INPUT_INCLUDE_NULLS|Y|Name of te variable to set so that Nulls are considered while parsing JSON files. If HOP_JSON_INPUT_INCLUDE_NULLS is "Y" then nulls will be included otherwise they will not be included (default behavior)
@@ -292,6 +293,7 @@ The default value is 1440 (one day).
Changing it to true will remove first and last enclosure symbol from the resulting string chunks.
|HOP_SYSTEM_HOSTNAME||You can use this variable to speed up hostname lookup.
Hostname lookup is performed by Hop so that it is capable of logging the server on which a workflow or pipeline is executed.
+|HOP_TIMEZONE||The default timezone in effect for date and timestamp conversion (IANA id, for example `Europe/Brussels`). Set automatically when a lifecycle environment with a timezone is enabled. See xref:projects/projects-environments.adoc[Projects and Environments].
|HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT|0|The maximum number of transform performance snapshots to keep in memory.
Set to 0 to keep all snapshots indefinitely (default)
|HOP_USE_NATIVE_FILE_DIALOG|N|Set this value to Y if you want to use the system file open/save dialog when browsing files
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 6a522811f71..e58809408be 100644
--- a/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
+++ b/engine/src/main/java/org/apache/hop/core/HopEnvironment.java
@@ -46,6 +46,7 @@
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;
@@ -122,6 +123,11 @@ public static void init(List pluginTypes) throws HopException {
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 8e85c22a4da..a8c17b3c78c 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 ce941f67d0e..f894c845df2 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/integration-tests/locale-eu/0001-format-number-defaults.hpl b/integration-tests/locale-eu/0001-format-number-defaults.hpl
new file mode 100644
index 00000000000..81c3361ccc9
--- /dev/null
+++ b/integration-tests/locale-eu/0001-format-number-defaults.hpl
@@ -0,0 +1,144 @@
+
+
+
+
+ 0001-format-number-defaults
+ Y
+ Number to String with empty decimal/grouping symbols, so FORMAT locale decides.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ amount
+ Number
+
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 1234,56
+
+
+
+
+ 128
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ String
+ -1
+ -1
+ #0.00
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/0002-parse-number-defaults.hpl b/integration-tests/locale-eu/0002-parse-number-defaults.hpl
new file mode 100644
index 00000000000..a5a13a72d52
--- /dev/null
+++ b/integration-tests/locale-eu/0002-parse-number-defaults.hpl
@@ -0,0 +1,186 @@
+
+
+
+
+ 0002-parse-number-defaults
+ Y
+ String to Number with empty decimal/grouping symbols. Input is US-style in locale-us and EU-style in locale-eu.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to number
+ Y
+
+
+ to number
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ amount
+ String
+
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 1234,56
+
+
+
+
+ 96
+ 128
+
+
+
+ to number
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ Number
+ -1
+ -1
+
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 272
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ String
+ -1
+ -1
+ #0.00
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 448
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 624
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/0003-parse-number-explicit.hpl b/integration-tests/locale-eu/0003-parse-number-explicit.hpl
new file mode 100644
index 00000000000..064eb39dd20
--- /dev/null
+++ b/integration-tests/locale-eu/0003-parse-number-explicit.hpl
@@ -0,0 +1,186 @@
+
+
+
+
+ 0003-parse-number-explicit
+ Y
+ Field-level decimal and grouping symbols win over the environment FORMAT locale.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to number
+ Y
+
+
+ to number
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ amount
+ String
+
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 1,234.56
+
+
+
+
+ 96
+ 128
+
+
+
+ to number
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ Number
+ -1
+ -1
+
+ N
+
+
+ N
+
+ .
+ ,
+
+
+
+
+
+
+ 272
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ String
+ -1
+ -1
+ #0.00
+ N
+
+
+ N
+
+ .
+ ,
+
+
+
+
+
+
+ 448
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 624
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/0004-format-date-defaults.hpl b/integration-tests/locale-eu/0004-format-date-defaults.hpl
new file mode 100644
index 00000000000..450287ac0c5
--- /dev/null
+++ b/integration-tests/locale-eu/0004-format-date-defaults.hpl
@@ -0,0 +1,144 @@
+
+
+
+
+ 0004-format-date-defaults
+ Y
+ Date to String with empty date locale so month names follow FORMAT, not the GUI language.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ when
+ Date
+ yyyy-MM-dd
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 2025-01-01
+
+
+
+
+ 128
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ when
+ when
+ String
+ -1
+ -1
+ MMMM yyyy
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/0005-format-timestamp-timezone.hpl b/integration-tests/locale-eu/0005-format-timestamp-timezone.hpl
new file mode 100644
index 00000000000..c56c994bd77
--- /dev/null
+++ b/integration-tests/locale-eu/0005-format-timestamp-timezone.hpl
@@ -0,0 +1,144 @@
+
+
+
+
+ 0005-format-timestamp-timezone
+ Y
+ Date to String with empty timezone so the offset follows the environment default.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ when
+ Date
+ yyyy-MM-dd HH:mm:ss
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 2025-06-15 12:00:00
+
+
+
+
+ 128
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ when
+ when
+ String
+ -1
+ -1
+ yyyy-MM-dd HH:mm Z
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/0006-regional-variables.hpl b/integration-tests/locale-eu/0006-regional-variables.hpl
new file mode 100644
index 00000000000..39e698e9810
--- /dev/null
+++ b/integration-tests/locale-eu/0006-regional-variables.hpl
@@ -0,0 +1,139 @@
+
+
+
+
+ 0006-regional-variables
+ Y
+ Effective FORMAT locale and timezone published as HOP_FORMAT_LOCALE and HOP_TIMEZONE.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ 1 row
+ Get variables
+ Y
+
+
+ Get variables
+ OUTPUT
+ Y
+
+
+
+ 1 row
+ RowGenerator
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 5000
+ FiveSecondsAgo
+ N
+ 1
+ now
+
+
+ 128
+ 128
+
+
+
+ Get variables
+ GetVariable
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ format_locale
+ ${HOP_FORMAT_LOCALE}
+ String
+
+
+
+
+ -1
+ -1
+ none
+
+
+ timezone
+ ${HOP_TIMEZONE}
+ String
+
+
+
+
+ -1
+ -1
+ none
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/datasets/golden-format-date-defaults.csv b/integration-tests/locale-eu/datasets/golden-format-date-defaults.csv
new file mode 100644
index 00000000000..3779c1d4e8c
--- /dev/null
+++ b/integration-tests/locale-eu/datasets/golden-format-date-defaults.csv
@@ -0,0 +1,2 @@
+when
+januari 2025
diff --git a/integration-tests/locale-eu/datasets/golden-format-number-defaults.csv b/integration-tests/locale-eu/datasets/golden-format-number-defaults.csv
new file mode 100644
index 00000000000..a305ac8a269
--- /dev/null
+++ b/integration-tests/locale-eu/datasets/golden-format-number-defaults.csv
@@ -0,0 +1,2 @@
+amount
+"1234,56"
diff --git a/integration-tests/locale-eu/datasets/golden-format-timestamp-timezone.csv b/integration-tests/locale-eu/datasets/golden-format-timestamp-timezone.csv
new file mode 100644
index 00000000000..410ab2be0fb
--- /dev/null
+++ b/integration-tests/locale-eu/datasets/golden-format-timestamp-timezone.csv
@@ -0,0 +1,2 @@
+when
+2025-06-15 12:00 +0200
diff --git a/integration-tests/locale-eu/datasets/golden-parse-number-defaults.csv b/integration-tests/locale-eu/datasets/golden-parse-number-defaults.csv
new file mode 100644
index 00000000000..a305ac8a269
--- /dev/null
+++ b/integration-tests/locale-eu/datasets/golden-parse-number-defaults.csv
@@ -0,0 +1,2 @@
+amount
+"1234,56"
diff --git a/integration-tests/locale-eu/datasets/golden-parse-number-explicit.csv b/integration-tests/locale-eu/datasets/golden-parse-number-explicit.csv
new file mode 100644
index 00000000000..6011df0a243
--- /dev/null
+++ b/integration-tests/locale-eu/datasets/golden-parse-number-explicit.csv
@@ -0,0 +1,2 @@
+amount
+1234.56
diff --git a/integration-tests/locale-eu/datasets/golden-regional-variables.csv b/integration-tests/locale-eu/datasets/golden-regional-variables.csv
new file mode 100644
index 00000000000..4eb7a0d620f
--- /dev/null
+++ b/integration-tests/locale-eu/datasets/golden-regional-variables.csv
@@ -0,0 +1,2 @@
+format_locale,timezone
+nl_BE,Europe/Brussels
diff --git a/integration-tests/locale-eu/dev-env-config.json b/integration-tests/locale-eu/dev-env-config.json
new file mode 100644
index 00000000000..00d07db900c
--- /dev/null
+++ b/integration-tests/locale-eu/dev-env-config.json
@@ -0,0 +1,19 @@
+{
+ "description": "HOP-2333 Test Environment Variables - DEV",
+ "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath": "${PROJECT_HOME}",
+ "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome": true,
+ "config": {
+ "variables": [
+ {
+ "name": "sample-var2",
+ "value": "ProvaDev2"
+ },
+ {
+ "name": "sample-var1",
+ "value": "ProvaDev1"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/locale-eu/hop-config.json b/integration-tests/locale-eu/hop-config.json
new file mode 100644
index 00000000000..42cbe97c522
--- /dev/null
+++ b/integration-tests/locale-eu/hop-config.json
@@ -0,0 +1,294 @@
+{
+ "variables": [
+ {
+ "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+ "value": "N",
+ "description": "System wide flag to allow lenient string to number conversion for backward compatibility. If this setting is set to \"Y\", an string starting with digits will be converted successfully into a number. (example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending on the decimal and grouping symbol). The default (N) will be to throw an error if non-numeric symbols are found in the string."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+ "value": "N",
+ "description": "System wide flag to ignore timezone while writing date/timestamp value to the database."
+ },
+ {
+ "name": "HOP_LOG_SIZE_LIMIT",
+ "value": "0",
+ "description": "The log size limit for all pipelines and workflows that don't have the \"log size limit\" property set in their respective properties."
+ },
+ {
+ "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+ "value": "N",
+ "description": "NULL vs Empty String. If this setting is set to Y, an empty string and null are different. Otherwise they are not."
+ },
+ {
+ "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+ "value": "0",
+ "description": "The maximum number of log lines that are kept internally by Hop. Set to 0 to keep all rows (default)"
+ },
+ {
+ "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+ "value": "1440",
+ "description": "The maximum age (in minutes) of a log line while being kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+ },
+ {
+ "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+ "value": "5000",
+ "description": "The maximum number of workflow trackers kept in memory"
+ },
+ {
+ "name": "HOP_MAX_ACTIONS_LOGGED",
+ "value": "5000",
+ "description": "The maximum number of action results kept in memory for logging purposes."
+ },
+ {
+ "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+ "value": "10000",
+ "description": "The maximum number of logging registry entries kept in memory for logging purposes."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_DELAY",
+ "value": "1000",
+ "description": "The hop log tab refresh delay."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+ "value": "1000",
+ "description": "The hop log tab refresh period."
+ },
+ {
+ "name": "HOP_PLUGIN_CLASSES",
+ "value": null,
+ "description": "A comma delimited list of classes to scan for plugin annotations"
+ },
+ {
+ "name": "HOP_PLUGIN_PACKAGES",
+ "value": null,
+ "description": "A comma delimited list of packages to scan for plugin annotations (warning: slow!!)"
+ },
+ {
+ "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+ "value": "0",
+ "description": "The maximum number of transform performance snapshots to keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+ },
+ {
+ "name": "HOP_ROWSET_GET_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an alternative rowset get timeout (in ms). This only makes a difference for extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_ROWSET_PUT_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an alternative rowset put timeout (in ms). This only makes a difference for extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_CORE_TRANSFORMS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the alternative location of the hop-transforms.xml file. You can use this to customize the list of available internal transforms outside of the codebase."
+ },
+ {
+ "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the alternative location of the hop-workflow-actions.xml file."
+ },
+ {
+ "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+ "value": "1440",
+ "description": "This project variable will set a time-out after which waiting, completed or stopped pipelines and workflows will be automatically cleaned up. The default value is 1440 (one day)."
+ },
+ {
+ "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+ "value": null,
+ "description": "Set this variable to an integer that will be returned as the Pan JVM exit code."
+ },
+ {
+ "name": "HOP_DISABLE_CONSOLE_LOGGING",
+ "value": "N",
+ "description": "Set this variable to Y to disable standard Hop logging to the console. (stdout)"
+ },
+ {
+ "name": "HOP_REDIRECT_STDERR",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stderr to Hop logging."
+ },
+ {
+ "name": "HOP_REDIRECT_STDOUT",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stdout to Hop logging."
+ },
+ {
+ "name": "HOP_DEFAULT_NUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default number format"
+ },
+ {
+ "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default bignumber format"
+ },
+ {
+ "name": "HOP_DEFAULT_INTEGER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default integer format"
+ },
+ {
+ "name": "HOP_DEFAULT_DATE_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default date format"
+ },
+ {
+ "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default timestamp format"
+ },
+ {
+ "name": "HOP_DEFAULT_SERVLET_ENCODING",
+ "value": null,
+ "description": "Defines the default encoding for servlets, leave it empty to use Java default encoding"
+ },
+ {
+ "name": "HOP_FAIL_ON_LOGGING_ERROR",
+ "value": "N",
+ "description": "Set this variable to Y when you want the workflow/pipeline fail with an error when the related logging process (e.g. to a database) fails."
+ },
+ {
+ "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+ "value": "N",
+ "description": "Set this variable to Y to set the minimum to NULL if NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN aggregate and MIN is set to the minimum value that is not NULL. See also the variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+ },
+ {
+ "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+ "value": "N",
+ "description": "Set this variable to Y to return 0 when all values within an aggregate are NULL. Otherwise by default a NULL is returned when all values are NULL."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for the Text File Output transform. Setting this to Ywill add no header row at all when the append option is enabled, regardless if the file is existing or not."
+ },
+ {
+ "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+ "value": "Hop",
+ "description": "Specifies the password encoder plugin to use by ID (Hop is the default)."
+ },
+ {
+ "name": "HOP_SYSTEM_HOSTNAME",
+ "value": null,
+ "description": "You can use this variable to speed up hostname lookup. Hostname lookup is performed by Hop so that it is capable of logging the server on which a workflow or pipeline is executed."
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPTORS",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptors for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptQueueSize for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+ "value": null,
+ "description": "A variable to configure jetty option: lowResourcesMaxIdleTime for Carte"
+ },
+ {
+ "name": "HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for the Merge Rows (diff) transform. Setting this to Y will use the data from the reference stream (instead of the comparison stream) in case the compared rows are identical."
+ },
+ {
+ "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+ "value": "false",
+ "description": "Set this variable to false to preserve enclosure symbol after splitting the string in the Split fields transform. Changing it to true will remove first and last enclosure symbol from the resulting string chunks."
+ },
+ {
+ "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+ "value": "false",
+ "description": "Set this variable to TRUE to allow your pipeline to pass 'null' fields and/or empty types."
+ },
+ {
+ "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+ "value": "false",
+ "description": "Set this variable to false to preserve global log variables defined in pipeline / workflow Properties -> Log panel. Changing it to true will clear it when export pipeline / workflow."
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+ "value": "1024",
+ "description": "This project variable is used by the Text File Output transform. It defines the max number of simultaneously open files within the transform. The transform will close/reopen files as necessary to insure the max is not exceeded"
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+ "value": "0",
+ "description": "This project variable is used by the Text File Output transform. It defines the max number of milliseconds between flushes of files opened by the transform."
+ },
+ {
+ "name": "HOP_USE_NATIVE_FILE_DIALOG",
+ "value": "N",
+ "description": "Set this value to Y if you want to use the system file open/save dialog when browsing files"
+ },
+ {
+ "name": "HOP_AUTO_CREATE_CONFIG",
+ "value": "Y",
+ "description": "Set this value to N if you don't want to automatically create a hop configuration file (hop-config.json) when it's missing"
+ }
+ ],
+ "LocaleDefault": "en_US",
+ "RegionalSettingsSource": "CUSTOM",
+ "RegionalSettingsLocale": "en_US",
+ "guiProperties": {
+ "FontFixedSize": "13",
+ "MaxUndo": "100",
+ "DarkMode": "Y",
+ "FontNoteSize": "13",
+ "ShowOSLook": "Y",
+ "FontFixedStyle": "0",
+ "FontNoteName": ".AppleSystemUIFont",
+ "FontFixedName": "Monospaced",
+ "FontGraphStyle": "0",
+ "FontDefaultSize": "13",
+ "GraphColorR": "255",
+ "FontGraphSize": "13",
+ "IconSize": "32",
+ "BackgroundColorB": "255",
+ "FontNoteStyle": "0",
+ "FontGraphName": ".AppleSystemUIFont",
+ "FontDefaultName": ".AppleSystemUIFont",
+ "GraphColorG": "255",
+ "UseGlobalFileBookmarks": "Y",
+ "FontDefaultStyle": "0",
+ "GraphColorB": "255",
+ "BackgroundColorR": "255",
+ "BackgroundColorG": "255",
+ "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+ "LineWidth": "1",
+ "ContextDialogShowCategories": "Y"
+ },
+ "projectsConfig": {
+ "enabled": true,
+ "projectMandatory": true,
+ "environmentMandatory": false,
+ "defaultProject": "default",
+ "defaultEnvironment": null,
+ "standardParentProject": "default",
+ "standardProjectsFolder": null,
+ "projectConfigurations": [
+ {
+ "projectName": "default",
+ "projectHome": "${HOP_CONFIG_FOLDER}",
+ "configFilename": "project-config.json"
+ }
+ ],
+ "lifecycleEnvironments": [
+ {
+ "name": "dev",
+ "purpose": "Testing",
+ "projectName": "default",
+ "formatLocale": "nl_BE",
+ "timeZone": "Europe/Brussels",
+ "configurationFiles": [
+ "${PROJECT_HOME}/dev-env-config.json"
+ ]
+ }
+ ],
+ "projectLifecycles": []
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/locale-eu/main-0001-format-number-defaults.hwf b/integration-tests/locale-eu/main-0001-format-number-defaults.hwf
new file mode 100644
index 00000000000..5783b2ae972
--- /dev/null
+++ b/integration-tests/locale-eu/main-0001-format-number-defaults.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0001-format-number-defaults
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0001-format-number-defaults
+
+ RunPipelineTests
+
+
+
+ 0001-format-number-defaults UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0001-format-number-defaults
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/main-0002-parse-number-defaults.hwf b/integration-tests/locale-eu/main-0002-parse-number-defaults.hwf
new file mode 100644
index 00000000000..61bcdae9894
--- /dev/null
+++ b/integration-tests/locale-eu/main-0002-parse-number-defaults.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0002-parse-number-defaults
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0002-parse-number-defaults
+
+ RunPipelineTests
+
+
+
+ 0002-parse-number-defaults UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0002-parse-number-defaults
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/main-0003-parse-number-explicit.hwf b/integration-tests/locale-eu/main-0003-parse-number-explicit.hwf
new file mode 100644
index 00000000000..6fad8b107ed
--- /dev/null
+++ b/integration-tests/locale-eu/main-0003-parse-number-explicit.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0003-parse-number-explicit
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0003-parse-number-explicit
+
+ RunPipelineTests
+
+
+
+ 0003-parse-number-explicit UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0003-parse-number-explicit
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/main-0004-format-date-defaults.hwf b/integration-tests/locale-eu/main-0004-format-date-defaults.hwf
new file mode 100644
index 00000000000..d7748ad6e72
--- /dev/null
+++ b/integration-tests/locale-eu/main-0004-format-date-defaults.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0004-format-date-defaults
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0004-format-date-defaults
+
+ RunPipelineTests
+
+
+
+ 0004-format-date-defaults UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0004-format-date-defaults
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/main-0005-format-timestamp-timezone.hwf b/integration-tests/locale-eu/main-0005-format-timestamp-timezone.hwf
new file mode 100644
index 00000000000..828be4321fa
--- /dev/null
+++ b/integration-tests/locale-eu/main-0005-format-timestamp-timezone.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0005-format-timestamp-timezone
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0005-format-timestamp-timezone
+
+ RunPipelineTests
+
+
+
+ 0005-format-timestamp-timezone UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0005-format-timestamp-timezone
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/main-0006-regional-variables.hwf b/integration-tests/locale-eu/main-0006-regional-variables.hwf
new file mode 100644
index 00000000000..3af981e5dfa
--- /dev/null
+++ b/integration-tests/locale-eu/main-0006-regional-variables.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0006-regional-variables
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0006-regional-variables
+
+ RunPipelineTests
+
+
+
+ 0006-regional-variables UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0006-regional-variables
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-eu/metadata/dataset/golden-format-date-defaults.json b/integration-tests/locale-eu/metadata/dataset/golden-format-date-defaults.json
new file mode 100644
index 00000000000..be999e00935
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/dataset/golden-format-date-defaults.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-format-date-defaults.csv",
+ "name": "golden-format-date-defaults",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "when"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-eu/metadata/dataset/golden-format-number-defaults.json b/integration-tests/locale-eu/metadata/dataset/golden-format-number-defaults.json
new file mode 100644
index 00000000000..123c4b6bced
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/dataset/golden-format-number-defaults.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-format-number-defaults.csv",
+ "name": "golden-format-number-defaults",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "amount"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-eu/metadata/dataset/golden-format-timestamp-timezone.json b/integration-tests/locale-eu/metadata/dataset/golden-format-timestamp-timezone.json
new file mode 100644
index 00000000000..2eaf7a0d279
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/dataset/golden-format-timestamp-timezone.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-format-timestamp-timezone.csv",
+ "name": "golden-format-timestamp-timezone",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "when"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-eu/metadata/dataset/golden-parse-number-defaults.json b/integration-tests/locale-eu/metadata/dataset/golden-parse-number-defaults.json
new file mode 100644
index 00000000000..6eef32f7c6c
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/dataset/golden-parse-number-defaults.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-parse-number-defaults.csv",
+ "name": "golden-parse-number-defaults",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "amount"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-eu/metadata/dataset/golden-parse-number-explicit.json b/integration-tests/locale-eu/metadata/dataset/golden-parse-number-explicit.json
new file mode 100644
index 00000000000..ec94d070208
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/dataset/golden-parse-number-explicit.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-parse-number-explicit.csv",
+ "name": "golden-parse-number-explicit",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "amount"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-eu/metadata/dataset/golden-regional-variables.json b/integration-tests/locale-eu/metadata/dataset/golden-regional-variables.json
new file mode 100644
index 00000000000..06ad3ceb8d2
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/dataset/golden-regional-variables.json
@@ -0,0 +1,24 @@
+{
+ "base_filename": "golden-regional-variables.csv",
+ "name": "golden-regional-variables",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "format_locale"
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "timezone"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-eu/metadata/pipeline-run-configuration/local.json b/integration-tests/locale-eu/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 00000000000..63794efcaf6
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,17 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "feedback_size": "50000",
+ "sample_size": "100",
+ "sample_type_in_gui": "Last",
+ "rowset_size": "10000",
+ "safe_mode": false,
+ "show_feedback": false,
+ "topo_sort": false,
+ "gather_metrics": false
+ }
+ },
+ "configurationVariables": [],
+ "name": "local",
+ "description": "Runs your pipelines locally with the standard local Hop pipeline engine"
+}
\ No newline at end of file
diff --git a/integration-tests/locale-eu/metadata/unit-test/0001-format-number-defaults UNIT.json b/integration-tests/locale-eu/metadata/unit-test/0001-format-number-defaults UNIT.json
new file mode 100644
index 00000000000..1d9bec0e32c
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/unit-test/0001-format-number-defaults UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "amount",
+ "data_set_field": "amount"
+ }
+ ],
+ "field_order": [
+ "amount"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-format-number-defaults"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0001-format-number-defaults UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0001-format-number-defaults.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-eu/metadata/unit-test/0002-parse-number-defaults UNIT.json b/integration-tests/locale-eu/metadata/unit-test/0002-parse-number-defaults UNIT.json
new file mode 100644
index 00000000000..c503716b543
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/unit-test/0002-parse-number-defaults UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "amount",
+ "data_set_field": "amount"
+ }
+ ],
+ "field_order": [
+ "amount"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-parse-number-defaults"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0002-parse-number-defaults UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0002-parse-number-defaults.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-eu/metadata/unit-test/0003-parse-number-explicit UNIT.json b/integration-tests/locale-eu/metadata/unit-test/0003-parse-number-explicit UNIT.json
new file mode 100644
index 00000000000..9e1125a6e3f
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/unit-test/0003-parse-number-explicit UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "amount",
+ "data_set_field": "amount"
+ }
+ ],
+ "field_order": [
+ "amount"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-parse-number-explicit"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0003-parse-number-explicit UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0003-parse-number-explicit.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-eu/metadata/unit-test/0004-format-date-defaults UNIT.json b/integration-tests/locale-eu/metadata/unit-test/0004-format-date-defaults UNIT.json
new file mode 100644
index 00000000000..15dc4f58b88
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/unit-test/0004-format-date-defaults UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "when",
+ "data_set_field": "when"
+ }
+ ],
+ "field_order": [
+ "when"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-format-date-defaults"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0004-format-date-defaults UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0004-format-date-defaults.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-eu/metadata/unit-test/0005-format-timestamp-timezone UNIT.json b/integration-tests/locale-eu/metadata/unit-test/0005-format-timestamp-timezone UNIT.json
new file mode 100644
index 00000000000..b0fc523fe1e
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/unit-test/0005-format-timestamp-timezone UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "when",
+ "data_set_field": "when"
+ }
+ ],
+ "field_order": [
+ "when"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-format-timestamp-timezone"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0005-format-timestamp-timezone UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0005-format-timestamp-timezone.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-eu/metadata/unit-test/0006-regional-variables UNIT.json b/integration-tests/locale-eu/metadata/unit-test/0006-regional-variables UNIT.json
new file mode 100644
index 00000000000..f38bb189df2
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/unit-test/0006-regional-variables UNIT.json
@@ -0,0 +1,32 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "format_locale",
+ "data_set_field": "format_locale"
+ },
+ {
+ "transform_field": "timezone",
+ "data_set_field": "timezone"
+ }
+ ],
+ "field_order": [
+ "format_locale"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-regional-variables"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0006-regional-variables UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0006-regional-variables.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-eu/metadata/workflow-run-configuration/local.json b/integration-tests/locale-eu/metadata/workflow-run-configuration/local.json
new file mode 100644
index 00000000000..e37a93039aa
--- /dev/null
+++ b/integration-tests/locale-eu/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,9 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "safe_mode": false
+ }
+ },
+ "name": "local",
+ "description": "Runs your workflows locally with the standard local Hop workflow engine"
+}
\ No newline at end of file
diff --git a/integration-tests/locale-eu/project-config.json b/integration-tests/locale-eu/project-config.json
new file mode 100644
index 00000000000..899c6c10927
--- /dev/null
+++ b/integration-tests/locale-eu/project-config.json
@@ -0,0 +1,15 @@
+{
+ "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath": "${PROJECT_HOME}",
+ "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome": true,
+ "config": {
+ "variables": [
+ {
+ "name": "HOP_LICENSE_HEADER_FILE",
+ "value": "${PROJECT_HOME}/../asf-header.txt",
+ "description": "This will automatically serialize the ASF license header into pipelines and workflows in the integration test projects"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/locale-us/0001-format-number-defaults.hpl b/integration-tests/locale-us/0001-format-number-defaults.hpl
new file mode 100644
index 00000000000..a33b81396bf
--- /dev/null
+++ b/integration-tests/locale-us/0001-format-number-defaults.hpl
@@ -0,0 +1,144 @@
+
+
+
+
+ 0001-format-number-defaults
+ Y
+ Number to String with empty decimal/grouping symbols, so FORMAT locale decides.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ amount
+ Number
+
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 1234.56
+
+
+
+
+ 128
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ String
+ -1
+ -1
+ #0.00
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/0002-parse-number-defaults.hpl b/integration-tests/locale-us/0002-parse-number-defaults.hpl
new file mode 100644
index 00000000000..52200c5d00c
--- /dev/null
+++ b/integration-tests/locale-us/0002-parse-number-defaults.hpl
@@ -0,0 +1,186 @@
+
+
+
+
+ 0002-parse-number-defaults
+ Y
+ String to Number with empty decimal/grouping symbols. Input is US-style in locale-us and EU-style in locale-eu.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to number
+ Y
+
+
+ to number
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ amount
+ String
+
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 1234.56
+
+
+
+
+ 96
+ 128
+
+
+
+ to number
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ Number
+ -1
+ -1
+
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 272
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ String
+ -1
+ -1
+ #0.00
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 448
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 624
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/0003-parse-number-explicit.hpl b/integration-tests/locale-us/0003-parse-number-explicit.hpl
new file mode 100644
index 00000000000..064eb39dd20
--- /dev/null
+++ b/integration-tests/locale-us/0003-parse-number-explicit.hpl
@@ -0,0 +1,186 @@
+
+
+
+
+ 0003-parse-number-explicit
+ Y
+ Field-level decimal and grouping symbols win over the environment FORMAT locale.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to number
+ Y
+
+
+ to number
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ amount
+ String
+
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 1,234.56
+
+
+
+
+ 96
+ 128
+
+
+
+ to number
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ Number
+ -1
+ -1
+
+ N
+
+
+ N
+
+ .
+ ,
+
+
+
+
+
+
+ 272
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ amount
+ amount
+ String
+ -1
+ -1
+ #0.00
+ N
+
+
+ N
+
+ .
+ ,
+
+
+
+
+
+
+ 448
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 624
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/0004-format-date-defaults.hpl b/integration-tests/locale-us/0004-format-date-defaults.hpl
new file mode 100644
index 00000000000..450287ac0c5
--- /dev/null
+++ b/integration-tests/locale-us/0004-format-date-defaults.hpl
@@ -0,0 +1,144 @@
+
+
+
+
+ 0004-format-date-defaults
+ Y
+ Date to String with empty date locale so month names follow FORMAT, not the GUI language.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ when
+ Date
+ yyyy-MM-dd
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 2025-01-01
+
+
+
+
+ 128
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ when
+ when
+ String
+ -1
+ -1
+ MMMM yyyy
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/0005-format-timestamp-timezone.hpl b/integration-tests/locale-us/0005-format-timestamp-timezone.hpl
new file mode 100644
index 00000000000..c56c994bd77
--- /dev/null
+++ b/integration-tests/locale-us/0005-format-timestamp-timezone.hpl
@@ -0,0 +1,144 @@
+
+
+
+
+ 0005-format-timestamp-timezone
+ Y
+ Date to String with empty timezone so the offset follows the environment default.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ sample
+ to string
+ Y
+
+
+ to string
+ OUTPUT
+ Y
+
+
+
+ sample
+ DataGrid
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ when
+ Date
+ yyyy-MM-dd HH:mm:ss
+
+
+
+ -1
+ -1
+ N
+
+
+
+
+ - 2025-06-15 12:00:00
+
+
+
+
+ 128
+ 128
+
+
+
+ to string
+ SelectValues
+
+ Y
+
+ 1
+
+ none
+
+
+
+ N
+
+ when
+ when
+ String
+ -1
+ -1
+ yyyy-MM-dd HH:mm Z
+ N
+
+
+ N
+
+
+
+
+
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/0006-regional-variables.hpl b/integration-tests/locale-us/0006-regional-variables.hpl
new file mode 100644
index 00000000000..39e698e9810
--- /dev/null
+++ b/integration-tests/locale-us/0006-regional-variables.hpl
@@ -0,0 +1,139 @@
+
+
+
+
+ 0006-regional-variables
+ Y
+ Effective FORMAT locale and timezone published as HOP_FORMAT_LOCALE and HOP_TIMEZONE.
+
+
+ Normal
+
+
+ N
+ 1000
+ 100
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+
+ 1 row
+ Get variables
+ Y
+
+
+ Get variables
+ OUTPUT
+ Y
+
+
+
+ 1 row
+ RowGenerator
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 5000
+ FiveSecondsAgo
+ N
+ 1
+ now
+
+
+ 128
+ 128
+
+
+
+ Get variables
+ GetVariable
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ format_locale
+ ${HOP_FORMAT_LOCALE}
+ String
+
+
+
+
+ -1
+ -1
+ none
+
+
+ timezone
+ ${HOP_TIMEZONE}
+ String
+
+
+
+
+ -1
+ -1
+ none
+
+
+
+
+ 320
+ 128
+
+
+
+ OUTPUT
+ Dummy
+
+ Y
+
+ 1
+
+ none
+
+
+
+
+ 512
+ 128
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/datasets/golden-format-date-defaults.csv b/integration-tests/locale-us/datasets/golden-format-date-defaults.csv
new file mode 100644
index 00000000000..bc5ba2c9f11
--- /dev/null
+++ b/integration-tests/locale-us/datasets/golden-format-date-defaults.csv
@@ -0,0 +1,2 @@
+when
+January 2025
diff --git a/integration-tests/locale-us/datasets/golden-format-number-defaults.csv b/integration-tests/locale-us/datasets/golden-format-number-defaults.csv
new file mode 100644
index 00000000000..6011df0a243
--- /dev/null
+++ b/integration-tests/locale-us/datasets/golden-format-number-defaults.csv
@@ -0,0 +1,2 @@
+amount
+1234.56
diff --git a/integration-tests/locale-us/datasets/golden-format-timestamp-timezone.csv b/integration-tests/locale-us/datasets/golden-format-timestamp-timezone.csv
new file mode 100644
index 00000000000..43361d219df
--- /dev/null
+++ b/integration-tests/locale-us/datasets/golden-format-timestamp-timezone.csv
@@ -0,0 +1,2 @@
+when
+2025-06-15 12:00 -0400
diff --git a/integration-tests/locale-us/datasets/golden-parse-number-defaults.csv b/integration-tests/locale-us/datasets/golden-parse-number-defaults.csv
new file mode 100644
index 00000000000..6011df0a243
--- /dev/null
+++ b/integration-tests/locale-us/datasets/golden-parse-number-defaults.csv
@@ -0,0 +1,2 @@
+amount
+1234.56
diff --git a/integration-tests/locale-us/datasets/golden-parse-number-explicit.csv b/integration-tests/locale-us/datasets/golden-parse-number-explicit.csv
new file mode 100644
index 00000000000..6011df0a243
--- /dev/null
+++ b/integration-tests/locale-us/datasets/golden-parse-number-explicit.csv
@@ -0,0 +1,2 @@
+amount
+1234.56
diff --git a/integration-tests/locale-us/datasets/golden-regional-variables.csv b/integration-tests/locale-us/datasets/golden-regional-variables.csv
new file mode 100644
index 00000000000..3a2a74b76e7
--- /dev/null
+++ b/integration-tests/locale-us/datasets/golden-regional-variables.csv
@@ -0,0 +1,2 @@
+format_locale,timezone
+en_US,America/New_York
diff --git a/integration-tests/locale-us/dev-env-config.json b/integration-tests/locale-us/dev-env-config.json
new file mode 100644
index 00000000000..00d07db900c
--- /dev/null
+++ b/integration-tests/locale-us/dev-env-config.json
@@ -0,0 +1,19 @@
+{
+ "description": "HOP-2333 Test Environment Variables - DEV",
+ "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath": "${PROJECT_HOME}",
+ "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome": true,
+ "config": {
+ "variables": [
+ {
+ "name": "sample-var2",
+ "value": "ProvaDev2"
+ },
+ {
+ "name": "sample-var1",
+ "value": "ProvaDev1"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/locale-us/hop-config.json b/integration-tests/locale-us/hop-config.json
new file mode 100644
index 00000000000..c1df3b219e9
--- /dev/null
+++ b/integration-tests/locale-us/hop-config.json
@@ -0,0 +1,294 @@
+{
+ "variables": [
+ {
+ "name": "HOP_LENIENT_STRING_TO_NUMBER_CONVERSION",
+ "value": "N",
+ "description": "System wide flag to allow lenient string to number conversion for backward compatibility. If this setting is set to \"Y\", an string starting with digits will be converted successfully into a number. (example: 192.168.1.1 will be converted into 192 or 192.168 or 192168 depending on the decimal and grouping symbol). The default (N) will be to throw an error if non-numeric symbols are found in the string."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_DB_IGNORE_TIMEZONE",
+ "value": "N",
+ "description": "System wide flag to ignore timezone while writing date/timestamp value to the database."
+ },
+ {
+ "name": "HOP_LOG_SIZE_LIMIT",
+ "value": "0",
+ "description": "The log size limit for all pipelines and workflows that don't have the \"log size limit\" property set in their respective properties."
+ },
+ {
+ "name": "HOP_EMPTY_STRING_DIFFERS_FROM_NULL",
+ "value": "N",
+ "description": "NULL vs Empty String. If this setting is set to Y, an empty string and null are different. Otherwise they are not."
+ },
+ {
+ "name": "HOP_MAX_LOG_SIZE_IN_LINES",
+ "value": "0",
+ "description": "The maximum number of log lines that are kept internally by Hop. Set to 0 to keep all rows (default)"
+ },
+ {
+ "name": "HOP_MAX_LOG_TIMEOUT_IN_MINUTES",
+ "value": "1440",
+ "description": "The maximum age (in minutes) of a log line while being kept internally by Hop. Set to 0 to keep all rows indefinitely (default)"
+ },
+ {
+ "name": "HOP_MAX_WORKFLOW_TRACKER_SIZE",
+ "value": "5000",
+ "description": "The maximum number of workflow trackers kept in memory"
+ },
+ {
+ "name": "HOP_MAX_ACTIONS_LOGGED",
+ "value": "5000",
+ "description": "The maximum number of action results kept in memory for logging purposes."
+ },
+ {
+ "name": "HOP_MAX_LOGGING_REGISTRY_SIZE",
+ "value": "10000",
+ "description": "The maximum number of logging registry entries kept in memory for logging purposes."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_DELAY",
+ "value": "1000",
+ "description": "The hop log tab refresh delay."
+ },
+ {
+ "name": "HOP_LOG_TAB_REFRESH_PERIOD",
+ "value": "1000",
+ "description": "The hop log tab refresh period."
+ },
+ {
+ "name": "HOP_PLUGIN_CLASSES",
+ "value": null,
+ "description": "A comma delimited list of classes to scan for plugin annotations"
+ },
+ {
+ "name": "HOP_PLUGIN_PACKAGES",
+ "value": null,
+ "description": "A comma delimited list of packages to scan for plugin annotations (warning: slow!!)"
+ },
+ {
+ "name": "HOP_TRANSFORM_PERFORMANCE_SNAPSHOT_LIMIT",
+ "value": "0",
+ "description": "The maximum number of transform performance snapshots to keep in memory. Set to 0 to keep all snapshots indefinitely (default)"
+ },
+ {
+ "name": "HOP_ROWSET_GET_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an alternative rowset get timeout (in ms). This only makes a difference for extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_ROWSET_PUT_TIMEOUT",
+ "value": "50",
+ "description": "The name of the variable that optionally contains an alternative rowset put timeout (in ms). This only makes a difference for extremely short lived pipelines."
+ },
+ {
+ "name": "HOP_CORE_TRANSFORMS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the alternative location of the hop-transforms.xml file. You can use this to customize the list of available internal transforms outside of the codebase."
+ },
+ {
+ "name": "HOP_CORE_WORKFLOW_ACTIONS_FILE",
+ "value": null,
+ "description": "The name of the project variable that will contain the alternative location of the hop-workflow-actions.xml file."
+ },
+ {
+ "name": "HOP_SERVER_OBJECT_TIMEOUT_MINUTES",
+ "value": "1440",
+ "description": "This project variable will set a time-out after which waiting, completed or stopped pipelines and workflows will be automatically cleaned up. The default value is 1440 (one day)."
+ },
+ {
+ "name": "HOP_PIPELINE_PAN_JVM_EXIT_CODE",
+ "value": null,
+ "description": "Set this variable to an integer that will be returned as the Pan JVM exit code."
+ },
+ {
+ "name": "HOP_DISABLE_CONSOLE_LOGGING",
+ "value": "N",
+ "description": "Set this variable to Y to disable standard Hop logging to the console. (stdout)"
+ },
+ {
+ "name": "HOP_REDIRECT_STDERR",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stderr to Hop logging."
+ },
+ {
+ "name": "HOP_REDIRECT_STDOUT",
+ "value": "N",
+ "description": "Set this variable to Y to redirect stdout to Hop logging."
+ },
+ {
+ "name": "HOP_DEFAULT_NUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default number format"
+ },
+ {
+ "name": "HOP_DEFAULT_BIGNUMBER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default bignumber format"
+ },
+ {
+ "name": "HOP_DEFAULT_INTEGER_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default integer format"
+ },
+ {
+ "name": "HOP_DEFAULT_DATE_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default date format"
+ },
+ {
+ "name": "HOP_DEFAULT_TIMESTAMP_FORMAT",
+ "value": null,
+ "description": "The name of the variable containing an alternative default timestamp format"
+ },
+ {
+ "name": "HOP_DEFAULT_SERVLET_ENCODING",
+ "value": null,
+ "description": "Defines the default encoding for servlets, leave it empty to use Java default encoding"
+ },
+ {
+ "name": "HOP_FAIL_ON_LOGGING_ERROR",
+ "value": "N",
+ "description": "Set this variable to Y when you want the workflow/pipeline fail with an error when the related logging process (e.g. to a database) fails."
+ },
+ {
+ "name": "HOP_AGGREGATION_MIN_NULL_IS_VALUED",
+ "value": "N",
+ "description": "Set this variable to Y to set the minimum to NULL if NULL is within an aggregate. Otherwise by default NULL is ignored by the MIN aggregate and MIN is set to the minimum value that is not NULL. See also the variable HOP_AGGREGATION_ALL_NULLS_ARE_ZERO."
+ },
+ {
+ "name": "HOP_AGGREGATION_ALL_NULLS_ARE_ZERO",
+ "value": "N",
+ "description": "Set this variable to Y to return 0 when all values within an aggregate are NULL. Otherwise by default a NULL is returned when all values are NULL."
+ },
+ {
+ "name": "HOP_COMPATIBILITY_TEXT_FILE_OUTPUT_APPEND_NO_HEADER",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for the Text File Output transform. Setting this to Ywill add no header row at all when the append option is enabled, regardless if the file is existing or not."
+ },
+ {
+ "name": "HOP_PASSWORD_ENCODER_PLUGIN",
+ "value": "Hop",
+ "description": "Specifies the password encoder plugin to use by ID (Hop is the default)."
+ },
+ {
+ "name": "HOP_SYSTEM_HOSTNAME",
+ "value": null,
+ "description": "You can use this variable to speed up hostname lookup. Hostname lookup is performed by Hop so that it is capable of logging the server on which a workflow or pipeline is executed."
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPTORS",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptors for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_ACCEPT_QUEUE_SIZE",
+ "value": null,
+ "description": "A variable to configure jetty option: acceptQueueSize for Carte"
+ },
+ {
+ "name": "HOP_SERVER_JETTY_RES_MAX_IDLE_TIME",
+ "value": null,
+ "description": "A variable to configure jetty option: lowResourcesMaxIdleTime for Carte"
+ },
+ {
+ "name": "HOP_COMPATIBILITY_MERGE_ROWS_USE_REFERENCE_STREAM_WHEN_IDENTICAL",
+ "value": "N",
+ "description": "Set this variable to Y for backward compatibility for the Merge Rows (diff) transform. Setting this to Y will use the data from the reference stream (instead of the comparison stream) in case the compared rows are identical."
+ },
+ {
+ "name": "HOP_SPLIT_FIELDS_REMOVE_ENCLOSURE",
+ "value": "false",
+ "description": "Set this variable to false to preserve enclosure symbol after splitting the string in the Split fields transform. Changing it to true will remove first and last enclosure symbol from the resulting string chunks."
+ },
+ {
+ "name": "HOP_ALLOW_EMPTY_FIELD_NAMES_AND_TYPES",
+ "value": "false",
+ "description": "Set this variable to TRUE to allow your pipeline to pass 'null' fields and/or empty types."
+ },
+ {
+ "name": "HOP_GLOBAL_LOG_VARIABLES_CLEAR_ON_EXPORT",
+ "value": "false",
+ "description": "Set this variable to false to preserve global log variables defined in pipeline / workflow Properties -> Log panel. Changing it to true will clear it when export pipeline / workflow."
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_COUNT",
+ "value": "1024",
+ "description": "This project variable is used by the Text File Output transform. It defines the max number of simultaneously open files within the transform. The transform will close/reopen files as necessary to insure the max is not exceeded"
+ },
+ {
+ "name": "HOP_FILE_OUTPUT_MAX_STREAM_LIFE",
+ "value": "0",
+ "description": "This project variable is used by the Text File Output transform. It defines the max number of milliseconds between flushes of files opened by the transform."
+ },
+ {
+ "name": "HOP_USE_NATIVE_FILE_DIALOG",
+ "value": "N",
+ "description": "Set this value to Y if you want to use the system file open/save dialog when browsing files"
+ },
+ {
+ "name": "HOP_AUTO_CREATE_CONFIG",
+ "value": "Y",
+ "description": "Set this value to N if you don't want to automatically create a hop configuration file (hop-config.json) when it's missing"
+ }
+ ],
+ "LocaleDefault": "en_US",
+ "RegionalSettingsSource": "CUSTOM",
+ "RegionalSettingsLocale": "en_US",
+ "guiProperties": {
+ "FontFixedSize": "13",
+ "MaxUndo": "100",
+ "DarkMode": "Y",
+ "FontNoteSize": "13",
+ "ShowOSLook": "Y",
+ "FontFixedStyle": "0",
+ "FontNoteName": ".AppleSystemUIFont",
+ "FontFixedName": "Monospaced",
+ "FontGraphStyle": "0",
+ "FontDefaultSize": "13",
+ "GraphColorR": "255",
+ "FontGraphSize": "13",
+ "IconSize": "32",
+ "BackgroundColorB": "255",
+ "FontNoteStyle": "0",
+ "FontGraphName": ".AppleSystemUIFont",
+ "FontDefaultName": ".AppleSystemUIFont",
+ "GraphColorG": "255",
+ "UseGlobalFileBookmarks": "Y",
+ "FontDefaultStyle": "0",
+ "GraphColorB": "255",
+ "BackgroundColorR": "255",
+ "BackgroundColorG": "255",
+ "WorkflowDialogStyle": "RESIZE,MAX,MIN",
+ "LineWidth": "1",
+ "ContextDialogShowCategories": "Y"
+ },
+ "projectsConfig": {
+ "enabled": true,
+ "projectMandatory": true,
+ "environmentMandatory": false,
+ "defaultProject": "default",
+ "defaultEnvironment": null,
+ "standardParentProject": "default",
+ "standardProjectsFolder": null,
+ "projectConfigurations": [
+ {
+ "projectName": "default",
+ "projectHome": "${HOP_CONFIG_FOLDER}",
+ "configFilename": "project-config.json"
+ }
+ ],
+ "lifecycleEnvironments": [
+ {
+ "name": "dev",
+ "purpose": "Testing",
+ "projectName": "default",
+ "formatLocale": "en_US",
+ "timeZone": "America/New_York",
+ "configurationFiles": [
+ "${PROJECT_HOME}/dev-env-config.json"
+ ]
+ }
+ ],
+ "projectLifecycles": []
+ }
+}
\ No newline at end of file
diff --git a/integration-tests/locale-us/main-0001-format-number-defaults.hwf b/integration-tests/locale-us/main-0001-format-number-defaults.hwf
new file mode 100644
index 00000000000..5783b2ae972
--- /dev/null
+++ b/integration-tests/locale-us/main-0001-format-number-defaults.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0001-format-number-defaults
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0001-format-number-defaults
+
+ RunPipelineTests
+
+
+
+ 0001-format-number-defaults UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0001-format-number-defaults
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/main-0002-parse-number-defaults.hwf b/integration-tests/locale-us/main-0002-parse-number-defaults.hwf
new file mode 100644
index 00000000000..61bcdae9894
--- /dev/null
+++ b/integration-tests/locale-us/main-0002-parse-number-defaults.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0002-parse-number-defaults
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0002-parse-number-defaults
+
+ RunPipelineTests
+
+
+
+ 0002-parse-number-defaults UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0002-parse-number-defaults
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/main-0003-parse-number-explicit.hwf b/integration-tests/locale-us/main-0003-parse-number-explicit.hwf
new file mode 100644
index 00000000000..6fad8b107ed
--- /dev/null
+++ b/integration-tests/locale-us/main-0003-parse-number-explicit.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0003-parse-number-explicit
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0003-parse-number-explicit
+
+ RunPipelineTests
+
+
+
+ 0003-parse-number-explicit UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0003-parse-number-explicit
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/main-0004-format-date-defaults.hwf b/integration-tests/locale-us/main-0004-format-date-defaults.hwf
new file mode 100644
index 00000000000..d7748ad6e72
--- /dev/null
+++ b/integration-tests/locale-us/main-0004-format-date-defaults.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0004-format-date-defaults
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0004-format-date-defaults
+
+ RunPipelineTests
+
+
+
+ 0004-format-date-defaults UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0004-format-date-defaults
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/main-0005-format-timestamp-timezone.hwf b/integration-tests/locale-us/main-0005-format-timestamp-timezone.hwf
new file mode 100644
index 00000000000..828be4321fa
--- /dev/null
+++ b/integration-tests/locale-us/main-0005-format-timestamp-timezone.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0005-format-timestamp-timezone
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0005-format-timestamp-timezone
+
+ RunPipelineTests
+
+
+
+ 0005-format-timestamp-timezone UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0005-format-timestamp-timezone
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/main-0006-regional-variables.hwf b/integration-tests/locale-us/main-0006-regional-variables.hwf
new file mode 100644
index 00000000000..3af981e5dfa
--- /dev/null
+++ b/integration-tests/locale-us/main-0006-regional-variables.hwf
@@ -0,0 +1,79 @@
+
+
+
+ main-0006-regional-variables
+ Y
+
+
+
+ -
+ 2026/08/29 12:00:00.000
+ -
+ 2026/08/29 12:00:00.000
+
+
+
+
+ Start
+
+ SPECIAL
+
+ N
+ 0
+ 0
+ 60
+ 12
+ 0
+ 1
+ 1
+ N
+ 128
+ 80
+
+
+
+ Run 0006-regional-variables
+
+ RunPipelineTests
+
+
+
+ 0006-regional-variables UNIT
+
+
+ N
+ 320
+ 80
+
+
+
+
+
+ Start
+ Run 0006-regional-variables
+ Y
+ Y
+ Y
+
+
+
+
+
+
diff --git a/integration-tests/locale-us/metadata/dataset/golden-format-date-defaults.json b/integration-tests/locale-us/metadata/dataset/golden-format-date-defaults.json
new file mode 100644
index 00000000000..be999e00935
--- /dev/null
+++ b/integration-tests/locale-us/metadata/dataset/golden-format-date-defaults.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-format-date-defaults.csv",
+ "name": "golden-format-date-defaults",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "when"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-us/metadata/dataset/golden-format-number-defaults.json b/integration-tests/locale-us/metadata/dataset/golden-format-number-defaults.json
new file mode 100644
index 00000000000..123c4b6bced
--- /dev/null
+++ b/integration-tests/locale-us/metadata/dataset/golden-format-number-defaults.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-format-number-defaults.csv",
+ "name": "golden-format-number-defaults",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "amount"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-us/metadata/dataset/golden-format-timestamp-timezone.json b/integration-tests/locale-us/metadata/dataset/golden-format-timestamp-timezone.json
new file mode 100644
index 00000000000..2eaf7a0d279
--- /dev/null
+++ b/integration-tests/locale-us/metadata/dataset/golden-format-timestamp-timezone.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-format-timestamp-timezone.csv",
+ "name": "golden-format-timestamp-timezone",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "when"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-us/metadata/dataset/golden-parse-number-defaults.json b/integration-tests/locale-us/metadata/dataset/golden-parse-number-defaults.json
new file mode 100644
index 00000000000..6eef32f7c6c
--- /dev/null
+++ b/integration-tests/locale-us/metadata/dataset/golden-parse-number-defaults.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-parse-number-defaults.csv",
+ "name": "golden-parse-number-defaults",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "amount"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-us/metadata/dataset/golden-parse-number-explicit.json b/integration-tests/locale-us/metadata/dataset/golden-parse-number-explicit.json
new file mode 100644
index 00000000000..ec94d070208
--- /dev/null
+++ b/integration-tests/locale-us/metadata/dataset/golden-parse-number-explicit.json
@@ -0,0 +1,16 @@
+{
+ "base_filename": "golden-parse-number-explicit.csv",
+ "name": "golden-parse-number-explicit",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "amount"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-us/metadata/dataset/golden-regional-variables.json b/integration-tests/locale-us/metadata/dataset/golden-regional-variables.json
new file mode 100644
index 00000000000..06ad3ceb8d2
--- /dev/null
+++ b/integration-tests/locale-us/metadata/dataset/golden-regional-variables.json
@@ -0,0 +1,24 @@
+{
+ "base_filename": "golden-regional-variables.csv",
+ "name": "golden-regional-variables",
+ "description": "",
+ "dataset_fields": [
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "format_locale"
+ },
+ {
+ "field_comment": "",
+ "field_length": -1,
+ "field_type": 2,
+ "field_precision": -1,
+ "field_format": "",
+ "field_name": "timezone"
+ }
+ ],
+ "folder_name": "${HOP_DATASETS_FOLDER}"
+}
diff --git a/integration-tests/locale-us/metadata/pipeline-run-configuration/local.json b/integration-tests/locale-us/metadata/pipeline-run-configuration/local.json
new file mode 100644
index 00000000000..63794efcaf6
--- /dev/null
+++ b/integration-tests/locale-us/metadata/pipeline-run-configuration/local.json
@@ -0,0 +1,17 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "feedback_size": "50000",
+ "sample_size": "100",
+ "sample_type_in_gui": "Last",
+ "rowset_size": "10000",
+ "safe_mode": false,
+ "show_feedback": false,
+ "topo_sort": false,
+ "gather_metrics": false
+ }
+ },
+ "configurationVariables": [],
+ "name": "local",
+ "description": "Runs your pipelines locally with the standard local Hop pipeline engine"
+}
\ No newline at end of file
diff --git a/integration-tests/locale-us/metadata/unit-test/0001-format-number-defaults UNIT.json b/integration-tests/locale-us/metadata/unit-test/0001-format-number-defaults UNIT.json
new file mode 100644
index 00000000000..1d9bec0e32c
--- /dev/null
+++ b/integration-tests/locale-us/metadata/unit-test/0001-format-number-defaults UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "amount",
+ "data_set_field": "amount"
+ }
+ ],
+ "field_order": [
+ "amount"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-format-number-defaults"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0001-format-number-defaults UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0001-format-number-defaults.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-us/metadata/unit-test/0002-parse-number-defaults UNIT.json b/integration-tests/locale-us/metadata/unit-test/0002-parse-number-defaults UNIT.json
new file mode 100644
index 00000000000..c503716b543
--- /dev/null
+++ b/integration-tests/locale-us/metadata/unit-test/0002-parse-number-defaults UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "amount",
+ "data_set_field": "amount"
+ }
+ ],
+ "field_order": [
+ "amount"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-parse-number-defaults"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0002-parse-number-defaults UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0002-parse-number-defaults.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-us/metadata/unit-test/0003-parse-number-explicit UNIT.json b/integration-tests/locale-us/metadata/unit-test/0003-parse-number-explicit UNIT.json
new file mode 100644
index 00000000000..9e1125a6e3f
--- /dev/null
+++ b/integration-tests/locale-us/metadata/unit-test/0003-parse-number-explicit UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "amount",
+ "data_set_field": "amount"
+ }
+ ],
+ "field_order": [
+ "amount"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-parse-number-explicit"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0003-parse-number-explicit UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0003-parse-number-explicit.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-us/metadata/unit-test/0004-format-date-defaults UNIT.json b/integration-tests/locale-us/metadata/unit-test/0004-format-date-defaults UNIT.json
new file mode 100644
index 00000000000..15dc4f58b88
--- /dev/null
+++ b/integration-tests/locale-us/metadata/unit-test/0004-format-date-defaults UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "when",
+ "data_set_field": "when"
+ }
+ ],
+ "field_order": [
+ "when"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-format-date-defaults"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0004-format-date-defaults UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0004-format-date-defaults.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-us/metadata/unit-test/0005-format-timestamp-timezone UNIT.json b/integration-tests/locale-us/metadata/unit-test/0005-format-timestamp-timezone UNIT.json
new file mode 100644
index 00000000000..b0fc523fe1e
--- /dev/null
+++ b/integration-tests/locale-us/metadata/unit-test/0005-format-timestamp-timezone UNIT.json
@@ -0,0 +1,28 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "when",
+ "data_set_field": "when"
+ }
+ ],
+ "field_order": [
+ "when"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-format-timestamp-timezone"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0005-format-timestamp-timezone UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0005-format-timestamp-timezone.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-us/metadata/unit-test/0006-regional-variables UNIT.json b/integration-tests/locale-us/metadata/unit-test/0006-regional-variables UNIT.json
new file mode 100644
index 00000000000..f38bb189df2
--- /dev/null
+++ b/integration-tests/locale-us/metadata/unit-test/0006-regional-variables UNIT.json
@@ -0,0 +1,32 @@
+{
+ "variableValues": [],
+ "database_replacements": [],
+ "autoOpening": true,
+ "basePath": "",
+ "golden_data_sets": [
+ {
+ "field_mappings": [
+ {
+ "transform_field": "format_locale",
+ "data_set_field": "format_locale"
+ },
+ {
+ "transform_field": "timezone",
+ "data_set_field": "timezone"
+ }
+ ],
+ "field_order": [
+ "format_locale"
+ ],
+ "transform_name": "OUTPUT",
+ "data_set_name": "golden-regional-variables"
+ }
+ ],
+ "input_data_sets": [],
+ "name": "0006-regional-variables UNIT",
+ "description": "",
+ "trans_test_tweaks": [],
+ "persist_filename": "",
+ "pipeline_filename": "./0006-regional-variables.hpl",
+ "test_type": "UNIT_TEST"
+}
diff --git a/integration-tests/locale-us/metadata/workflow-run-configuration/local.json b/integration-tests/locale-us/metadata/workflow-run-configuration/local.json
new file mode 100644
index 00000000000..e37a93039aa
--- /dev/null
+++ b/integration-tests/locale-us/metadata/workflow-run-configuration/local.json
@@ -0,0 +1,9 @@
+{
+ "engineRunConfiguration": {
+ "Local": {
+ "safe_mode": false
+ }
+ },
+ "name": "local",
+ "description": "Runs your workflows locally with the standard local Hop workflow engine"
+}
\ No newline at end of file
diff --git a/integration-tests/locale-us/project-config.json b/integration-tests/locale-us/project-config.json
new file mode 100644
index 00000000000..899c6c10927
--- /dev/null
+++ b/integration-tests/locale-us/project-config.json
@@ -0,0 +1,15 @@
+{
+ "metadataBaseFolder": "${PROJECT_HOME}/metadata",
+ "unitTestsBasePath": "${PROJECT_HOME}",
+ "dataSetsCsvFolder": "${PROJECT_HOME}/datasets",
+ "enforcingExecutionInHome": true,
+ "config": {
+ "variables": [
+ {
+ "name": "HOP_LICENSE_HEADER_FILE",
+ "value": "${PROJECT_HOME}/../asf-header.txt",
+ "description": "This will automatically serialize the ASF license header into pipelines and workflows in the integration test projects"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentRegionalSettings.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentRegionalSettings.java
new file mode 100644
index 00000000000..2e37a38be22
--- /dev/null
+++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/EnvironmentRegionalSettings.java
@@ -0,0 +1,110 @@
+/*
+ * 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.projects.environment;
+
+import java.time.ZoneId;
+import java.util.Arrays;
+import java.util.Locale;
+import java.util.TimeZone;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.util.EnvUtil;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.RegionalSettings;
+
+/**
+ * Applies a lifecycle environment's FORMAT locale and timezone on top of the installation regional
+ * settings. Empty fields inherit. Invalid values are logged and skipped rather than aborting
+ * environment enablement.
+ */
+public final class EnvironmentRegionalSettings {
+
+ private EnvironmentRegionalSettings() {
+ // utility
+ }
+
+ /**
+ * Installs the environment's regional settings (when present) and publishes the effective FORMAT
+ * locale and timezone as variables so pipelines can see what they are running under.
+ */
+ public static void apply(
+ ILogChannel log, LifecycleEnvironment environment, IVariables variables) {
+ String source = "installation:" + RegionalSettings.getInstance().getSource().name();
+
+ if (environment != null) {
+ String envName = Const.NVL(environment.getName(), "");
+ if (applyFormatLocale(log, environment.getFormatLocale(), envName)) {
+ source = "environment:" + envName;
+ }
+ if (applyTimeZone(log, environment.getTimeZone(), envName)
+ && source.startsWith("installation:")) {
+ source = "environment:" + envName;
+ }
+ }
+
+ if (variables != null) {
+ variables.setVariable(
+ Const.HOP_FORMAT_LOCALE, Locale.getDefault(Locale.Category.FORMAT).toString());
+ variables.setVariable(Const.HOP_TIMEZONE, TimeZone.getDefault().getID());
+ }
+
+ RegionalSettings.logEffective(log, source);
+ }
+
+ private static boolean applyFormatLocale(ILogChannel log, String formatLocale, String envName) {
+ if (StringUtils.isEmpty(formatLocale)) {
+ return false;
+ }
+ Locale parsed = EnvUtil.createLocale(formatLocale);
+ if (parsed == null || !Arrays.asList(Locale.getAvailableLocales()).contains(parsed)) {
+ if (log != null) {
+ log.logBasic(
+ "Environment '"
+ + envName
+ + "' format locale '"
+ + formatLocale
+ + "' is not available in this JVM; inheriting installation regional settings.");
+ }
+ return false;
+ }
+ Locale.setDefault(Locale.Category.FORMAT, parsed);
+ return true;
+ }
+
+ private static boolean applyTimeZone(ILogChannel log, String timeZoneId, String envName) {
+ if (StringUtils.isEmpty(timeZoneId)) {
+ return false;
+ }
+ if (!ZoneId.getAvailableZoneIds().contains(timeZoneId)) {
+ if (log != null) {
+ log.logBasic(
+ "Environment '"
+ + envName
+ + "' timezone '"
+ + timeZoneId
+ + "' is not a recognised IANA id; leaving the JVM default timezone in place.");
+ }
+ return false;
+ }
+ TimeZone timeZone = TimeZone.getTimeZone(timeZoneId);
+ TimeZone.setDefault(timeZone);
+ System.setProperty("user.timezone", timeZone.getID());
+ return true;
+ }
+}
diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java
index 6c0eba8af87..c4b5a76b665 100644
--- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java
+++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironment.java
@@ -22,6 +22,9 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.Setter;
import org.apache.hop.core.IAttributes;
/**
@@ -31,6 +34,8 @@
* namespaced group/key/value settings without hard-wiring fields into this class. Groups are
* persisted under {@code attributesMap} in hop-config projects configuration.
*/
+@Getter
+@Setter
public class LifecycleEnvironment implements IAttributes {
private String name;
@@ -41,9 +46,24 @@ public class LifecycleEnvironment implements IAttributes {
private String canvasText;
+ /**
+ * FORMAT locale for number, currency and date conversion when this environment is enabled
+ * (language_COUNTRY, for example {@code nl_BE}). Empty inherits the installation regional
+ * settings.
+ */
+ private String formatLocale;
+
+ /**
+ * Default timezone for date and timestamp conversion when this environment is enabled (IANA id,
+ * for example {@code Europe/Brussels}). Empty inherits the JVM default.
+ */
+ private String timeZone;
+
private List configurationFiles;
/** Group → (key → value); see {@link IAttributes}. */
+ @Getter(AccessLevel.NONE)
+ @Setter(AccessLevel.NONE)
private Map> attributesMap;
public LifecycleEnvironment() {
@@ -65,7 +85,12 @@ public LifecycleEnvironment(LifecycleEnvironment env) {
this.purpose = env.purpose;
this.projectName = env.projectName;
this.canvasText = env.canvasText;
- this.configurationFiles = new ArrayList<>(env.configurationFiles);
+ this.formatLocale = env.formatLocale;
+ this.timeZone = env.timeZone;
+ this.configurationFiles =
+ env.configurationFiles != null
+ ? new ArrayList<>(env.configurationFiles)
+ : new ArrayList<>();
this.attributesMap = deepCopyAttributes(env.attributesMap);
}
@@ -103,86 +128,6 @@ public int hashCode() {
return Objects.hash(name);
}
- /**
- * Gets name
- *
- * @return value of name
- */
- public String getName() {
- return name;
- }
-
- /**
- * @param name The name to set
- */
- public void setName(String name) {
- this.name = name;
- }
-
- /**
- * Gets purpose
- *
- * @return value of purpose
- */
- public String getPurpose() {
- return purpose;
- }
-
- /**
- * @param purpose The purpose to set
- */
- public void setPurpose(String purpose) {
- this.purpose = purpose;
- }
-
- /**
- * Gets projectName
- *
- * @return value of projectName
- */
- public String getProjectName() {
- return projectName;
- }
-
- /**
- * @param projectName The projectName to set
- */
- public void setProjectName(String projectName) {
- this.projectName = projectName;
- }
-
- /**
- * Gets canvasText — optional large watermark drawn top-right on pipeline/workflow canvases.
- *
- * @return value of canvasText
- */
- public String getCanvasText() {
- return canvasText;
- }
-
- /**
- * @param canvasText The canvas text to set (empty/null means do not draw)
- */
- public void setCanvasText(String canvasText) {
- this.canvasText = canvasText;
- }
-
- /**
- * Gets configurationFiles
- *
- * @return value of configurationFiles
- */
- public List getConfigurationFiles() {
- return configurationFiles;
- }
-
- /**
- * @param configurationFiles The configurationFiles to set
- */
- public void setConfigurationFiles(List configurationFiles) {
- this.configurationFiles = configurationFiles;
- }
-
@Override
public void setAttributesMap(Map> attributesMap) {
this.attributesMap = attributesMap != null ? attributesMap : new HashMap<>();
diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
index 67769b52fdb..c6fd4a4c4aa 100644
--- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
+++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/environment/LifecycleEnvironmentDialog.java
@@ -18,8 +18,12 @@
package org.apache.hop.projects.environment;
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.TimeZone;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.vfs2.FileObject;
import org.apache.hop.core.AttributesContext;
@@ -29,10 +33,12 @@
import org.apache.hop.core.extension.ExtensionPointHandler;
import org.apache.hop.core.extension.HopExtensionPoint;
import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.util.EnvUtil;
import org.apache.hop.core.variables.DescribedVariable;
import org.apache.hop.core.variables.IVariables;
import org.apache.hop.core.vfs.HopVfs;
import org.apache.hop.i18n.BaseMessages;
+import org.apache.hop.i18n.RegionalSettingsPreview;
import org.apache.hop.projects.config.ProjectsConfig;
import org.apache.hop.projects.config.ProjectsConfigSingleton;
import org.apache.hop.projects.project.ProjectConfig;
@@ -48,6 +54,7 @@
import org.apache.hop.ui.core.gui.GuiResource;
import org.apache.hop.ui.core.gui.WindowProperty;
import org.apache.hop.ui.core.widget.ColumnInfo;
+import org.apache.hop.ui.core.widget.ComboFilterPopup;
import org.apache.hop.ui.core.widget.TableView;
import org.apache.hop.ui.hopgui.HopGui;
import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
@@ -61,8 +68,10 @@
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.Dialog;
import org.eclipse.swt.widgets.Event;
+import org.eclipse.swt.widgets.Group;
import org.eclipse.swt.widgets.Label;
import org.eclipse.swt.widgets.Shell;
import org.eclipse.swt.widgets.TableItem;
@@ -83,6 +92,12 @@ public class LifecycleEnvironmentDialog extends Dialog {
private Combo wPurpose;
private Combo wProject;
private Text wCanvasText;
+ private Combo wFormatLocale;
+ private Combo wTimeZone;
+ private Label wNumberPreview;
+ private Label wDatePreview;
+ private Label wTimeZonePreview;
+ private List localeList;
private TableView wConfigFiles;
private IVariables variables;
@@ -103,6 +118,9 @@ public class LifecycleEnvironmentDialog extends Dialog {
private boolean updatingSuggestedName;
+ /** Skip environment-refresh flags while widgets are populated from the environment. */
+ private boolean loadingData;
+
/** Localized purpose label → fixed English suffix (for new-environment name suggestion). */
private Map knownPurposeSuffixes;
@@ -167,6 +185,7 @@ public String open() {
wTabFolder.setLayoutData(fdTabs);
createGeneralTab(wTabFolder, margin);
+ createRegionalTab(wTabFolder, margin);
createConfigurationFilesTab(wTabFolder, margin);
// Optional plugins (marketplace, resource checks, …) contribute extra tabs
@@ -299,6 +318,192 @@ private void createGeneralTab(CTabFolder folder, int margin) {
wCanvasText.setLayoutData(fdCanvasText);
}
+ private void createRegionalTab(CTabFolder folder, int margin) {
+ CTabItem tab = new CTabItem(folder, SWT.NONE);
+ tab.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Tab.Regional"));
+ Composite comp = new Composite(folder, SWT.NONE);
+ PropsUi.setLook(comp);
+ FormLayout layout = new FormLayout();
+ layout.marginWidth = PropsUi.getFormMargin();
+ layout.marginHeight = PropsUi.getFormMargin();
+ comp.setLayout(layout);
+ tab.setControl(comp);
+
+ int middle = props.getMiddlePct();
+
+ Label wlHelp = new Label(comp, SWT.LEFT | SWT.WRAP);
+ PropsUi.setLook(wlHelp);
+ wlHelp.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Regional.Help"));
+ FormData fdlHelp = new FormData();
+ fdlHelp.left = new FormAttachment(0, 0);
+ fdlHelp.right = new FormAttachment(100, 0);
+ fdlHelp.top = new FormAttachment(0, margin);
+ wlHelp.setLayoutData(fdlHelp);
+
+ Label wlFormatLocale = new Label(comp, SWT.RIGHT);
+ PropsUi.setLook(wlFormatLocale);
+ wlFormatLocale.setText(
+ BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Label.FormatLocale"));
+ FormData fdlFormatLocale = new FormData();
+ fdlFormatLocale.left = new FormAttachment(0, 0);
+ fdlFormatLocale.right = new FormAttachment(middle, 0);
+ fdlFormatLocale.top = new FormAttachment(wlHelp, margin * 2);
+ wlFormatLocale.setLayoutData(fdlFormatLocale);
+
+ localeList = buildLocaleList();
+ wFormatLocale = new Combo(comp, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wFormatLocale);
+ wFormatLocale.setItems(localeComboItems());
+ wFormatLocale.setToolTipText(
+ BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ToolTip.FormatLocale"));
+ FormData fdFormatLocale = new FormData();
+ fdFormatLocale.left = new FormAttachment(middle, margin);
+ fdFormatLocale.right = new FormAttachment(100, 0);
+ fdFormatLocale.top = new FormAttachment(wlFormatLocale, 0, SWT.CENTER);
+ wFormatLocale.setLayoutData(fdFormatLocale);
+ ComboFilterPopup.attach(wFormatLocale, () -> Arrays.asList(wFormatLocale.getItems()), null);
+ wFormatLocale.addListener(SWT.Modify, this::onRegionalChanged);
+
+ Label wlTimeZone = new Label(comp, SWT.RIGHT);
+ PropsUi.setLook(wlTimeZone);
+ wlTimeZone.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Label.TimeZone"));
+ FormData fdlTimeZone = new FormData();
+ fdlTimeZone.left = new FormAttachment(0, 0);
+ fdlTimeZone.right = new FormAttachment(middle, 0);
+ fdlTimeZone.top = new FormAttachment(wFormatLocale, margin);
+ wlTimeZone.setLayoutData(fdlTimeZone);
+
+ wTimeZone = new Combo(comp, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ PropsUi.setLook(wTimeZone);
+ wTimeZone.setItems(timeZoneComboItems());
+ wTimeZone.setToolTipText(
+ BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.ToolTip.TimeZone"));
+ FormData fdTimeZone = new FormData();
+ fdTimeZone.left = new FormAttachment(middle, margin);
+ fdTimeZone.right = new FormAttachment(100, 0);
+ fdTimeZone.top = new FormAttachment(wlTimeZone, 0, SWT.CENTER);
+ wTimeZone.setLayoutData(fdTimeZone);
+ ComboFilterPopup.attach(wTimeZone, () -> Arrays.asList(wTimeZone.getItems()), null);
+ wTimeZone.addListener(SWT.Modify, this::onRegionalChanged);
+
+ Group preview = new Group(comp, SWT.SHADOW_NONE);
+ PropsUi.setLook(preview);
+ preview.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Regional.Preview"));
+ FormLayout previewLayout = new FormLayout();
+ previewLayout.marginWidth = PropsUi.getFormMargin();
+ previewLayout.marginHeight = PropsUi.getFormMargin();
+ preview.setLayout(previewLayout);
+ FormData fdPreview = new FormData();
+ fdPreview.left = new FormAttachment(0, 0);
+ fdPreview.right = new FormAttachment(100, 0);
+ fdPreview.top = new FormAttachment(wTimeZone, margin * 2);
+ preview.setLayoutData(fdPreview);
+
+ wNumberPreview =
+ createPreviewRow(
+ preview, "LifecycleEnvironmentDialog.Regional.Preview.Number", null, margin);
+ wDatePreview =
+ createPreviewRow(
+ preview, "LifecycleEnvironmentDialog.Regional.Preview.Date", wNumberPreview, margin);
+ wTimeZonePreview =
+ createPreviewRow(
+ preview, "LifecycleEnvironmentDialog.Regional.Preview.TimeZone", wDatePreview, margin);
+ }
+
+ private List 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 String inheritLabel() {
+ return BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Regional.Inherit");
+ }
+
+ private String[] localeComboItems() {
+ String[] items = new String[localeList.size() + 1];
+ items[0] = inheritLabel();
+ for (int i = 0; i < localeList.size(); i++) {
+ items[i + 1] = localeList.get(i).getDisplayName();
+ }
+ return items;
+ }
+
+ private String[] timeZoneComboItems() {
+ String[] zones = EnvUtil.getTimeZones();
+ String[] items = new String[zones.length + 1];
+ items[0] = inheritLabel();
+ System.arraycopy(zones, 0, items, 1, zones.length);
+ return items;
+ }
+
+ private void onRegionalChanged(Event event) {
+ if (!loadingData) {
+ needingEnvironmentRefresh = true;
+ }
+ refreshRegionalPreview();
+ }
+
+ private void refreshRegionalPreview() {
+ if (wNumberPreview == null || wNumberPreview.isDisposed()) {
+ return;
+ }
+ Locale locale = selectedFormatLocale();
+ if (locale == null) {
+ locale = Locale.getDefault(Locale.Category.FORMAT);
+ }
+ RegionalSettingsPreview preview = RegionalSettingsPreview.of(locale);
+ wNumberPreview.setText(preview.getNumber());
+ wDatePreview.setText(preview.getLongDate());
+
+ String timeZoneId = selectedTimeZoneId();
+ TimeZone timeZone =
+ timeZoneId == null ? TimeZone.getDefault() : TimeZone.getTimeZone(timeZoneId);
+ wTimeZonePreview.setText(timeZone.getID() + " (" + timeZone.getDisplayName() + ")");
+ }
+
+ private Locale selectedFormatLocale() {
+ int index = wFormatLocale.indexOf(wFormatLocale.getText());
+ if (index <= 0) {
+ return null;
+ }
+ return localeList.get(index - 1);
+ }
+
+ private String selectedTimeZoneId() {
+ String text = wTimeZone.getText();
+ if (StringUtils.isEmpty(text) || inheritLabel().equals(text)) {
+ return null;
+ }
+ return text;
+ }
+
+ 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;
+ }
+
private void createConfigurationFilesTab(CTabFolder folder, int margin) {
CTabItem tab = new CTabItem(folder, SWT.NONE);
tab.setText(BaseMessages.getString(PKG, "LifecycleEnvironmentDialog.Tab.ConfigurationFiles"));
@@ -729,6 +934,7 @@ public void dispose() {
}
private void getData() {
+ loadingData = true;
ProjectsConfig config = ProjectsConfigSingleton.getConfig();
String developmentLabel =
@@ -797,6 +1003,40 @@ private void getData() {
if (!environment.getConfigurationFiles().isEmpty()) {
wConfigFiles.setSelection(new int[] {0});
}
+
+ selectFormatLocale(environment.getFormatLocale());
+ selectTimeZone(environment.getTimeZone());
+ refreshRegionalPreview();
+ loadingData = false;
+ }
+
+ private void selectFormatLocale(String localeCode) {
+ if (StringUtils.isEmpty(localeCode)) {
+ wFormatLocale.setText(inheritLabel());
+ return;
+ }
+ Locale locale = EnvUtil.createLocale(localeCode);
+ int index = locale == null ? -1 : localeList.indexOf(locale);
+ if (index < 0 && locale != null) {
+ localeList = new ArrayList<>(localeList);
+ localeList.add(locale);
+ wFormatLocale.setItems(localeComboItems());
+ index = localeList.indexOf(locale);
+ }
+ if (index >= 0) {
+ wFormatLocale.select(index + 1);
+ wFormatLocale.setText(wFormatLocale.getItem(index + 1));
+ } else {
+ wFormatLocale.setText(localeCode);
+ }
+ }
+
+ private void selectTimeZone(String timeZoneId) {
+ if (StringUtils.isEmpty(timeZoneId)) {
+ wTimeZone.setText(inheritLabel());
+ return;
+ }
+ wTimeZone.setText(timeZoneId);
}
private boolean isNewEnvironment() {
@@ -851,6 +1091,9 @@ private void getInfo(LifecycleEnvironment env) {
env.setPurpose(wPurpose.getText());
env.setProjectName(wProject.getText());
env.setCanvasText(wCanvasText.getText());
+ Locale formatLocale = selectedFormatLocale();
+ env.setFormatLocale(formatLocale == null ? null : formatLocale.toString());
+ env.setTimeZone(selectedTimeZoneId());
env.getConfigurationFiles().clear();
for (TableItem item : wConfigFiles.getNonEmptyItems()) {
diff --git a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
index 020cef141dd..b70581f6f6b 100644
--- a/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
+++ b/plugins/misc/projects/src/main/java/org/apache/hop/projects/util/ProjectsUtil.java
@@ -40,6 +40,7 @@
import org.apache.hop.metadata.util.HopMetadataUtil;
import org.apache.hop.projects.config.ProjectsConfig;
import org.apache.hop.projects.config.ProjectsConfigSingleton;
+import org.apache.hop.projects.environment.EnvironmentRegionalSettings;
import org.apache.hop.projects.environment.LifecycleEnvironment;
import org.apache.hop.projects.project.ParentProjectFolderSynchronizer;
import org.apache.hop.projects.project.Project;
@@ -101,6 +102,10 @@ public static void enableProject(
//
project.modifyVariables(variables, projectConfig, configurationFiles, environmentName);
+ LifecycleEnvironment environment =
+ StringUtils.isNotEmpty(environmentName) ? config.findEnvironment(environmentName) : null;
+ EnvironmentRegionalSettings.apply(log, environment, variables);
+
// Re-bind the process-global two-way password encoder from project/environment variables
// (HOP_PASSWORD_ENCODER_PLUGIN, HOP_AES_ENCODER_KEY / HOP_AES_ENCODER_KEY_FILE). This resets
// AES keys between projects and allows falling back to Hop obfuscation when unset.
diff --git a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties
index 041a367a3fd..e887418c2e3 100644
--- a/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties
+++ b/plugins/misc/projects/src/main/resources/org/apache/hop/projects/environment/messages/messages_en_US.properties
@@ -34,7 +34,18 @@ LifecycleEnvironmentDialog.Purpose.Text.Production=Production
LifecycleEnvironmentDialog.Purpose.Text.Testing=Testing
LifecycleEnvironmentDialog.Shell.Name=Environment Properties
LifecycleEnvironmentDialog.Tab.General=General
+LifecycleEnvironmentDialog.Tab.Regional=Regional
LifecycleEnvironmentDialog.Tab.ConfigurationFiles=Configuration files
+LifecycleEnvironmentDialog.Regional.Help=Number, currency and date formats, and the default timezone, used when this environment is enabled in Hop GUI, hop-run and hop-server. Leave a field on Inherit to use the installation regional settings from the Configuration perspective. Field-level decimal, grouping and date locale options still override these defaults. The effective values are published as '${HOP_FORMAT_LOCALE}' and '${HOP_TIMEZONE}'.
+LifecycleEnvironmentDialog.Label.FormatLocale=Format locale
+LifecycleEnvironmentDialog.ToolTip.FormatLocale=Locale used for decimal separator, grouping separator, currency and date formats. Independent of the Hop GUI language. Empty inherits the installation regional settings.
+LifecycleEnvironmentDialog.Label.TimeZone=Timezone
+LifecycleEnvironmentDialog.ToolTip.TimeZone=IANA timezone used as the default for date and timestamp conversion, for example Europe/Brussels. Empty inherits the JVM default.
+LifecycleEnvironmentDialog.Regional.Inherit=Inherit installation / OS
+LifecycleEnvironmentDialog.Regional.Preview=Preview
+LifecycleEnvironmentDialog.Regional.Preview.Number=Number:
+LifecycleEnvironmentDialog.Regional.Preview.Date=Long date:
+LifecycleEnvironmentDialog.Regional.Preview.TimeZone=Timezone:
LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Title=Project required
LifecycleEnvironmentDialog.ImportVariables.ProjectRequired.Message=Please select the project associated with this environment before importing variables.
LifecycleEnvironmentDialog.ImportVariables.NoneFound.Title=No variables to import
diff --git a/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentRegionalSettingsTest.java b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentRegionalSettingsTest.java
new file mode 100644
index 00000000000..ab8fa967985
--- /dev/null
+++ b/plugins/misc/projects/src/test/java/org/apache/hop/projects/environment/EnvironmentRegionalSettingsTest.java
@@ -0,0 +1,116 @@
+/*
+ * 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.projects.environment;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Locale;
+import java.util.TimeZone;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.exception.HopValueException;
+import org.apache.hop.core.logging.ILogChannel;
+import org.apache.hop.core.logging.LogChannel;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.value.ValueMetaNumber;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.rules.RestoreHopEnvironmentExtension;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+@ExtendWith(RestoreHopEnvironmentExtension.class)
+class EnvironmentRegionalSettingsTest {
+
+ private static final ILogChannel LOG = LogChannel.GENERAL;
+
+ @Test
+ void italianEnvironmentFormatsNumbersWithAComma() throws HopValueException {
+ LifecycleEnvironment environment = new LifecycleEnvironment("it", "Testing", "p", null);
+ environment.setFormatLocale("it_IT");
+
+ EnvironmentRegionalSettings.apply(LOG, environment, new Variables());
+
+ assertEquals(Locale.ITALY, Locale.getDefault(Locale.Category.FORMAT));
+ IValueMeta valueMeta = new ValueMetaNumber("n");
+ String converted = valueMeta.getString(10000.23d);
+ assertTrue(converted.contains(","), "Expected an Italian decimal separator, got: " + converted);
+ }
+
+ @Test
+ void switchingEnvironmentFlipsTheDecimalSeparator() throws HopValueException {
+ LifecycleEnvironment italian = new LifecycleEnvironment("it", "Testing", "p", null);
+ italian.setFormatLocale("it_IT");
+ EnvironmentRegionalSettings.apply(LOG, italian, new Variables());
+ assertTrue(new ValueMetaNumber("n").getString(10000.23d).contains(","));
+
+ LifecycleEnvironment us = new LifecycleEnvironment("us", "Testing", "p", null);
+ us.setFormatLocale("en_US");
+ EnvironmentRegionalSettings.apply(LOG, us, new Variables());
+ assertTrue(new ValueMetaNumber("n").getString(10000.23d).contains("."));
+ assertEquals(Locale.US, Locale.getDefault(Locale.Category.FORMAT));
+ }
+
+ @Test
+ void emptyEnvironmentFieldsLeaveTheInstallationSettingsInPlace() {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.GERMANY);
+ TimeZone.setDefault(TimeZone.getTimeZone("Europe/Berlin"));
+
+ LifecycleEnvironment environment = new LifecycleEnvironment("dev", "Testing", "p", null);
+ EnvironmentRegionalSettings.apply(LOG, environment, new Variables());
+
+ assertEquals(Locale.GERMANY, Locale.getDefault(Locale.Category.FORMAT));
+ assertEquals("Europe/Berlin", TimeZone.getDefault().getID());
+ }
+
+ @Test
+ void invalidLocaleAndTimezoneAreIgnored() {
+ Locale.setDefault(Locale.Category.FORMAT, Locale.US);
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
+
+ LifecycleEnvironment environment = new LifecycleEnvironment("bad", "Testing", "p", null);
+ environment.setFormatLocale("not_A_Locale");
+ environment.setTimeZone("Not/AZone");
+ EnvironmentRegionalSettings.apply(LOG, environment, new Variables());
+
+ assertEquals(Locale.US, Locale.getDefault(Locale.Category.FORMAT));
+ assertEquals("UTC", TimeZone.getDefault().getID());
+ }
+
+ @Test
+ void timezoneIsInstalledAndPublishedAsAVariable() {
+ LifecycleEnvironment environment = new LifecycleEnvironment("be", "Testing", "p", null);
+ environment.setTimeZone("Europe/Brussels");
+ Variables variables = new Variables();
+
+ EnvironmentRegionalSettings.apply(LOG, environment, variables);
+
+ assertEquals("Europe/Brussels", TimeZone.getDefault().getID());
+ assertEquals("Europe/Brussels", variables.getVariable(Const.HOP_TIMEZONE));
+ }
+
+ @Test
+ void formatLocaleIsPublishedAsAVariable() {
+ LifecycleEnvironment environment = new LifecycleEnvironment("be", "Testing", "p", null);
+ environment.setFormatLocale("nl_BE");
+ Variables variables = new Variables();
+
+ EnvironmentRegionalSettings.apply(LOG, environment, variables);
+
+ assertEquals("nl_BE", variables.getVariable(Const.HOP_FORMAT_LOCALE));
+ }
+}
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 cc61fef5714..b274747416a 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 14b3a8ae8d3..490fe837ada 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 cbda06fa440..b0f6dcd0d10 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.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;
@@ -85,7 +84,7 @@
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;
@@ -592,7 +591,7 @@ public static void main(String[] arguments) {
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 a69c6f5483a..f28eba11b14 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
@@ -24,12 +24,9 @@
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;
@@ -124,7 +121,6 @@ public class ConfigGuiOptionsTab {
private Button wMetricsPanelShowDataVolume;
private Button wMetricsPanelShowDataVolumeIn;
private Button wMetricsPanelShowDataVolumeOut;
- private Combo wDefaultLocale;
private Combo wAutoLayoutDirection;
private Text wAutoLayoutLayerSpacing;
@@ -268,15 +264,6 @@ public void reloadValues() {
// 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;
@@ -337,24 +324,6 @@ public void addGuiOptionsTab(CTabFolder wTabFolder) {
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(
@@ -1352,15 +1321,6 @@ private void saveValues() {
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 00000000000..5eb575568b3
--- /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 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 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 45936bfa781..cdd84bb60e0 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
@@ -384,3 +384,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: