diff --git a/src/modules/locale/CMakeLists.txt b/src/modules/locale/CMakeLists.txt
index bad6042a696edbd6e25dea2ce9d7a5e85ceffec7..b631f77f770642a5d0f284e66806e461ae02909b 100644
--- a/src/modules/locale/CMakeLists.txt
+++ b/src/modules/locale/CMakeLists.txt
@@ -22,6 +22,7 @@ calamares_add_plugin(locale
         Config.cpp
         LCLocaleDialog.cpp
         LocaleConfiguration.cpp
+        LocaleNames.cpp
         LocalePage.cpp
         LocaleViewStep.cpp
         SetTimezoneJob.cpp
@@ -39,7 +40,7 @@ calamares_add_plugin(locale
 
 calamares_add_test(
     localetest
-    SOURCES Tests.cpp Config.cpp LocaleConfiguration.cpp SetTimezoneJob.cpp timezonewidget/TimeZoneImage.cpp
+    SOURCES Tests.cpp Config.cpp LocaleConfiguration.cpp LocaleNames.cpp SetTimezoneJob.cpp timezonewidget/TimeZoneImage.cpp
     DEFINITIONS SOURCE_DIR="${CMAKE_CURRENT_LIST_DIR}/images" DEBUG_TIMEZONES=1
     LIBRARIES Qt5::Gui
 )
diff --git a/src/modules/locale/LocaleConfiguration.cpp b/src/modules/locale/LocaleConfiguration.cpp
index 17953f079aa3d1fe871a107a27405f382276184f..c62b1ab0816f000112cc6844e2283105412f0d0f 100644
--- a/src/modules/locale/LocaleConfiguration.cpp
+++ b/src/modules/locale/LocaleConfiguration.cpp
@@ -9,11 +9,13 @@
  */
 
 #include "LocaleConfiguration.h"
+#include "LocaleNames.h"
 
 #include "utils/Logger.h"
 
 #include <QLocale>
 #include <QRegularExpression>
+#include <QVector>
 
 LocaleConfiguration::LocaleConfiguration()
     : explicit_lang( false )
@@ -40,107 +42,114 @@ LocaleConfiguration::setLanguage( const QString& localeName )
     m_lang = localeName;
 }
 
-
-LocaleConfiguration
-LocaleConfiguration::fromLanguageAndLocation( const QString& languageLocale,
-                                              const QStringList& availableLocales,
-                                              const QString& countryCode )
+static LocaleNameParts
+updateCountry( LocaleNameParts p, const QString& country )
 {
-    cDebug() << "Mapping" << languageLocale << "in" << countryCode << "to locale.";
-    QString language = languageLocale.split( '_' ).first();
-    QString region;
-    if ( language.contains( '@' ) )
-    {
-        auto r = language.split( '@' );
-        language = r.first();
-        region = r[ 1 ];  // second()
-    }
-
-    // Either an exact match, or the whole language part matches
-    // (followed by .<encoding> or _<country>
-    QStringList linesForLanguage = availableLocales.filter( QRegularExpression( language + "[._]" ) );
-    cDebug() << Logger::SubEntry << "Matching" << linesForLanguage;
+    p.country = country;
+    return p;
+}
 
-    QString lang;
-    if ( linesForLanguage.isEmpty() || languageLocale.isEmpty() )
+static QPair< int, LocaleNameParts >
+identifyBestLanguageMatch( const LocaleNameParts& referenceLocale, QVector< LocaleNameParts >& others )
+{
+    std::sort( others.begin(),
+               others.end(),
+               [ & ]( const LocaleNameParts& lhs, const LocaleNameParts& rhs )
+               { return referenceLocale.similarity( lhs ) < referenceLocale.similarity( rhs ); } );
+    // The best match is at the end
+    LocaleNameParts best_match = others.last();
+    if ( !( referenceLocale.similarity( best_match ) > LocaleNameParts::no_match ) )
     {
-        lang = "en_US.UTF-8";
+        cDebug() << Logger::SubEntry << "Got no good match for" << referenceLocale.name();
+        return { LocaleNameParts::no_match, LocaleNameParts {} };
     }
-    else if ( linesForLanguage.length() == 1 )
+    else
     {
-        lang = linesForLanguage.first();
+        cDebug() << Logger::SubEntry << "Got best match for" << referenceLocale.name() << "as" << best_match.name();
+        return { referenceLocale.similarity( best_match ), best_match };
     }
+}
 
-    // lang could still be empty if we found multiple locales that satisfy myLanguage
-    const QString combinedLanguageAndCountry = QString( "%1_%2" ).arg( language ).arg( countryCode );
-    if ( lang.isEmpty() && region.isEmpty() )
-    {
-        auto l = linesForLanguage.filter(
-            QRegularExpression( combinedLanguageAndCountry + "[._]" ) );  // no regional variants
-        if ( l.length() == 1 )
-        {
-            lang = l.first();
-        }
-    }
+/** @brief Returns the QString from @p availableLocales that best-matches.
+ */
+static LocaleNameParts
+identifyBestLanguageMatch( const QString& languageLocale,
+                           const QStringList& availableLocales,
+                           const QString& countryCode )
+{
+    const QString default_lang = QStringLiteral( "en_US.UTF-8" );
 
-    // The following block was inspired by Ubiquity, scripts/localechooser-apply.
-    // No copyright statement found in file, assuming GPL v2 or later.
-    /*  # In the special cases of Portuguese and Chinese, selecting a
-        # different location may imply a different dialect of the language.
-        # In such cases, make LANG reflect the selected language (for
-        # messages, character types, and collation) and make the other
-        # locale categories reflect the selected location. */
-    if ( language == "pt" || language == "zh" )
+    const LocaleNameParts self = LocaleNameParts::fromName( languageLocale );
+    if ( self.isValid() && !availableLocales.isEmpty() )
     {
-        cDebug() << Logger::SubEntry << "Special-case Portuguese and Chinese";
-        QString proposedLocale = QString( "%1_%2" ).arg( language ).arg( countryCode );
-        for ( const QString& line : linesForLanguage )
+        QVector< LocaleNameParts > others;
+        others.resize( availableLocales.length() );  // Makes default structs
+        std::transform( availableLocales.begin(), availableLocales.end(), others.begin(), LocaleNameParts::fromName );
+
+        // Keep track of the best match in various attempts
+        int best_score = LocaleNameParts::no_match;
+        LocaleNameParts best_match;
+
+        // Check with the unmodified language setting
         {
-            if ( line.contains( proposedLocale ) )
+            auto [ score, match ] = identifyBestLanguageMatch( self, others );
+            if ( score >= LocaleNameParts::complete_match )
+            {
+                return match;
+            }
+            else if ( score > best_score )
             {
-                cDebug() << Logger::SubEntry << "Country-variant" << line << "chosen.";
-                lang = line;
-                break;
+                best_match = match;
             }
         }
-    }
-    if ( lang.isEmpty() && !region.isEmpty() )
-    {
-        cDebug() << Logger::SubEntry << "Special-case region @" << region;
-        QString proposedRegion = QString( "@%1" ).arg( region );
-        for ( const QString& line : linesForLanguage )
+
+
+        // .. but it might match **better** with the chosen location country Code
         {
-            if ( line.startsWith( language ) && line.contains( proposedRegion ) )
+            auto [ score, match ] = identifyBestLanguageMatch( updateCountry( self, countryCode ), others );
+            if ( score >= LocaleNameParts::complete_match )
+            {
+                return match;
+            }
+            else if ( score > best_score )
             {
-                cDebug() << Logger::SubEntry << "Region-variant" << line << "chosen.";
-                lang = line;
-                break;
+                best_match = match;
             }
         }
-    }
 
-
-    // If we found no good way to set a default lang, do a search with the whole
-    // language locale and pick the first result, if any.
-    if ( lang.isEmpty() )
-    {
-        for ( const QString& line : availableLocales )
+        // .. or better yet with the QLocale-derived country
         {
-            if ( line.startsWith( languageLocale ) )
+            const QString localeCountry = LocaleNameParts::fromName( QLocale( languageLocale ).name() ).country;
+            auto [ score, match ] = identifyBestLanguageMatch( updateCountry( self, localeCountry ), others );
+            if ( score >= LocaleNameParts::complete_match )
+            {
+                return match;
+            }
+            else if ( score > best_score )
             {
-                lang = line;
-                break;
+                best_match = match;
             }
         }
+
+        if ( best_match.isValid() )
+        {
+            cDebug() << Logger::SubEntry << "Matched best with" << best_match.name();
+            return best_match;
+        }
     }
 
     // Else we have an unrecognized or unsupported locale, all we can do is go with
     // en_US.UTF-8 UTF-8. This completes all default language setting guesswork.
-    if ( lang.isEmpty() )
-    {
-        lang = "en_US.UTF-8";
-    }
+    return LocaleNameParts::fromName( default_lang );
+}
 
+LocaleConfiguration
+LocaleConfiguration::fromLanguageAndLocation( const QString& languageLocale,
+                                              const QStringList& availableLocales,
+                                              const QString& countryCode )
+{
+    cDebug() << "Mapping" << languageLocale << "in" << countryCode << "to locale.";
+    const auto bestLocale = identifyBestLanguageMatch( languageLocale, availableLocales, countryCode );
 
     // The following block was inspired by Ubiquity, scripts/localechooser-apply.
     // No copyright statement found in file, assuming GPL v2 or later.
@@ -188,34 +197,16 @@ LocaleConfiguration::fromLanguageAndLocation( const QString& languageLocale,
     // We make a proposed locale based on the UI language and the timezone's country. There is no
     // guarantee that this will be a valid, supported locale (often it won't).
     QString lc_formats;
-    const QString combined = QString( "%1_%2" ).arg( language ).arg( countryCode );
-    if ( lang.isEmpty() )
+    const QString combined = QString( "%1_%2" ).arg( bestLocale.language ).arg( countryCode );
+    if ( availableLocales.contains( bestLocale.language ) )
     {
-        cDebug() << Logger::SubEntry << "Looking up formats for" << combinedLanguageAndCountry;
-        // We look up if it's a supported locale.
-        for ( const QString& line : availableLocales )
-        {
-            if ( line.startsWith( combinedLanguageAndCountry ) )
-            {
-                lang = line;
-                lc_formats = line;
-                break;
-            }
-        }
+        cDebug() << Logger::SubEntry << "Exact formats match for language tag" << bestLocale.language;
+        lc_formats = bestLocale.language;
     }
-    else
+    else if ( availableLocales.contains( combined ) )
     {
-        if ( availableLocales.contains( lang ) )
-        {
-            cDebug() << Logger::SubEntry << "Exact formats match for language tag" << lang;
-            lc_formats = lang;
-        }
-        else if ( availableLocales.contains( combinedLanguageAndCountry ) )
-        {
-            cDebug() << Logger::SubEntry << "Exact formats match for combined" << combinedLanguageAndCountry;
-            lang = combinedLanguageAndCountry;
-            lc_formats = combinedLanguageAndCountry;
-        }
+        cDebug() << Logger::SubEntry << "Exact formats match for combined" << combined;
+        lc_formats = combined;
     }
 
     if ( lc_formats.isEmpty() )
@@ -303,12 +294,7 @@ LocaleConfiguration::fromLanguageAndLocation( const QString& languageLocale,
 
     // If we cannot make a good choice for a given country we go with the LANG
     // setting, which defaults to en_US.UTF-8 UTF-8 if all else fails.
-    if ( lc_formats.isEmpty() )
-    {
-        lc_formats = lang;
-    }
-
-    return LocaleConfiguration( lang, lc_formats );
+    return LocaleConfiguration( bestLocale.name(), lc_formats.isEmpty() ? bestLocale.name() : lc_formats );
 }
 
 
diff --git a/src/modules/locale/LocaleNames.cpp b/src/modules/locale/LocaleNames.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..401aa4809f6307de74ad6538c67f8ec7b4625139
--- /dev/null
+++ b/src/modules/locale/LocaleNames.cpp
@@ -0,0 +1,90 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2022 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+
+#include "LocaleNames.h"
+
+#include "utils/Logger.h"
+
+#include <QRegularExpression>
+
+LocaleNameParts
+LocaleNameParts::fromName( const QString& name )
+{
+    auto requireAndRemoveLeadingChar = []( QChar c, QString s )
+    {
+        if ( s.startsWith( c ) )
+        {
+            return s.remove( 0, 1 );
+        }
+        else
+        {
+            return QString();
+        }
+    };
+
+    auto parts = QRegularExpression( "^([a-zA-Z]+)(_[a-zA-Z]+)?(\\.[-a-zA-Z0-9]+)?(@[a-zA-Z]+)?" ).match( name );
+    const QString calamaresLanguage = parts.captured( 1 );
+    const QString calamaresCountry = requireAndRemoveLeadingChar( '_', parts.captured( 2 ) );
+    const QString calamaresEncoding = requireAndRemoveLeadingChar( '.', parts.captured( 3 ) );
+    const QString calamaresRegion = requireAndRemoveLeadingChar( '@', parts.captured( 4 ) );
+
+    if ( calamaresLanguage.isEmpty() )
+    {
+        return LocaleNameParts {};
+    }
+    else
+    {
+        return LocaleNameParts { calamaresLanguage, calamaresCountry, calamaresRegion, calamaresEncoding };
+    }
+}
+
+QString
+LocaleNameParts::name() const
+{
+    // We don't want QStringView to a temporary; force conversion
+    auto insertLeadingChar = []( QChar c, QString s ) -> QString
+    {
+        if ( s.isEmpty() )
+        {
+            return QString();
+        }
+        else
+        {
+            return c + s;
+        }
+    };
+
+    if ( !isValid() )
+    {
+        return QString();
+    }
+    else
+    {
+        return language + insertLeadingChar( '_', country ) + insertLeadingChar( '.', encoding )
+            + insertLeadingChar( '@', region );
+    }
+}
+
+
+int
+LocaleNameParts::similarity( const LocaleNameParts& other ) const
+{
+    if ( !isValid() || !other.isValid() )
+    {
+        return 0;
+    }
+    if ( language != other.language )
+    {
+        return 0;
+    }
+    const auto matched_region = ( region == other.region ? 30 : 0 );
+    const auto matched_country = ( country == other.country ? ( country.isEmpty() ? 10 : 20 ) : 0 );
+    const auto no_other_country_given = ( ( country != other.country && other.country.isEmpty() ) ? 10 : 0 );
+    return 50 + matched_region + matched_country + no_other_country_given;
+}
diff --git a/src/modules/locale/LocaleNames.h b/src/modules/locale/LocaleNames.h
new file mode 100644
index 0000000000000000000000000000000000000000..8498aa28a4967f6769f3240c74a649a3cd178cfe
--- /dev/null
+++ b/src/modules/locale/LocaleNames.h
@@ -0,0 +1,46 @@
+/* === This file is part of Calamares - <https://calamares.io> ===
+ *
+ *   SPDX-FileCopyrightText: 2022 Adriaan de Groot <groot@kde.org>
+ *   SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ *   Calamares is Free Software: see the License-Identifier above.
+ *
+ */
+
+#ifndef LOCALENAMES_H
+#define LOCALENAMES_H
+
+#include <QString>
+
+/** @brief parts of a locale-name (e.g. "ar_LY.UTF-8", split apart)
+ *
+ * These are created from lines in `/usr/share/i18n/SUPPORTED`,
+ * which lists all the locales supported by the system (there
+ * are also other sources of the same).
+ *
+ */
+struct LocaleNameParts
+{
+    QString language;  // e.g. "ar"
+    QString country;  // e.g. "LY" (may be empty)
+    QString region;  // e.g. "@valencia" (may be empty)
+    QString encoding;  // e.g. "UTF-8" (may be empty)
+
+    bool isValid() const { return !language.isEmpty(); }
+    QString name() const;
+
+    static LocaleNameParts fromName( const QString& name );
+
+    static inline constexpr const int no_match = 0;
+    static inline constexpr const int complete_match = 100;
+
+    /** @brief Compute similarity-score with another locale-name.
+     *
+     * Similarity is driven by language and region, then country.
+     * Returns a number between 0 (no similarity, e.g. the
+     * language is different) and 100 (complete match).
+     */
+    int similarity( const LocaleNameParts& other ) const;
+};
+
+#endif
diff --git a/src/modules/locale/Tests.cpp b/src/modules/locale/Tests.cpp
index 23f9b5b3e10b9c1632aaedbcdc866ff7eefd74ad..56327154a8a646502493df57a0f5e66488cc05dd 100644
--- a/src/modules/locale/Tests.cpp
+++ b/src/modules/locale/Tests.cpp
@@ -9,9 +9,12 @@
 
 #include "Config.h"
 #include "LocaleConfiguration.h"
+#include "LocaleNames.h"
 #include "timezonewidget/TimeZoneImage.h"
 
+#include "Settings.h"
 #include "locale/TimeZone.h"
+#include "locale/TranslationsModel.h"
 #include "utils/Logger.h"
 
 #include <QtTest/QtTest>
@@ -25,6 +28,9 @@ public:
     LocaleTests();
     ~LocaleTests() override;
 
+    // Implementation of data for MappingNeon and MappingFreeBSD
+    void MappingData();
+
 private Q_SLOTS:
     void initTestCase();
     // Check the sample config file is processed correctly
@@ -43,6 +49,21 @@ private Q_SLOTS:
     void testLanguageDetection_data();
     void testLanguageDetection();
     void testLanguageDetectionValencia();
+
+    // Check that the test-data is available and ok
+    void testKDENeonLanguageData();
+    void testLocaleNameParts();
+
+    // Check realistic language mapping for issue 2008
+    void testLanguageMappingNeon_data();
+    void testLanguageMappingNeon();
+    void testLanguageMappingFreeBSD_data();
+    void testLanguageMappingFreeBSD();
+    void testLanguageSimilarity();
+
+private:
+    QStringList m_KDEneonLocales;
+    QStringList m_FreeBSDLocales;
 };
 
 QTEST_MAIN( LocaleTests )
@@ -55,6 +76,12 @@ LocaleTests::~LocaleTests() {}
 void
 LocaleTests::initTestCase()
 {
+    Logger::setupLogLevel( Logger::LOGDEBUG );
+    const auto* settings = Calamares::Settings::instance();
+    if ( !settings )
+    {
+        (void)new Calamares::Settings( true );
+    }
 }
 
 void
@@ -280,10 +307,10 @@ LocaleTests::testLanguageDetection_data()
     QTest::newRow( "english (US)" ) << QStringLiteral( "en" ) << QStringLiteral( "US" )
                                     << QStringLiteral( "en_US.UTF-8" );
     QTest::newRow( "english (CA)" ) << QStringLiteral( "en" ) << QStringLiteral( "CA" )
-                                    << QStringLiteral( "en" );  // because it's first in the list
+                                    << QStringLiteral( "en_US.UTF-8" );
     QTest::newRow( "english (GB)" ) << QStringLiteral( "en" ) << QStringLiteral( "GB" )
                                     << QStringLiteral( "en_GB.UTF-8" );
-    QTest::newRow( "english (NL)" ) << QStringLiteral( "en" ) << QStringLiteral( "NL" ) << QStringLiteral( "en" );
+    QTest::newRow( "english (NL)" ) << QStringLiteral( "en" ) << QStringLiteral( "NL" ) << QStringLiteral( "en_US.UTF-8" );
 
     QTest::newRow( "portuguese (PT)" ) << QStringLiteral( "pt" ) << QStringLiteral( "PT" )
                                        << QStringLiteral( "pt_PT.UTF-8" );
@@ -293,11 +320,11 @@ LocaleTests::testLanguageDetection_data()
                                        << QStringLiteral( "pt_BR.UTF-8" );
 
     QTest::newRow( "catalan ()" ) << QStringLiteral( "ca" ) << QStringLiteral( "" )
-                                  << QStringLiteral( "ca_AD.UTF-8" );  // no country given? Matches first
+                                  << QStringLiteral( "ca_ES.UTF-8" );  // no country given? Matches QLocale-default
     QTest::newRow( "catalan (ES)" ) << QStringLiteral( "ca" ) << QStringLiteral( "ES" )
                                     << QStringLiteral( "ca_ES.UTF-8" );
     QTest::newRow( "catalan (NL)" ) << QStringLiteral( "ca" ) << QStringLiteral( "NL" )
-                                    << QStringLiteral( "ca_AD.UTF-8" );
+                                    << QStringLiteral( "ca_ES.UTF-8" );
     QTest::newRow( "catalan (@valencia)" ) << QStringLiteral( "ca@valencia" ) << QStringLiteral( "ES" )
                                            << QStringLiteral( "ca_ES@valencia" );  // Prefers regional variant
     QTest::newRow( "catalan (@valencia_NL)" )
@@ -344,7 +371,7 @@ LocaleTests::testLanguageDetectionValencia()
     {
         auto r = LocaleConfiguration::fromLanguageAndLocation(
             QStringLiteral( "sr" ), availableLocales, QStringLiteral( "NL" ) );
-        QCOMPARE( r.language(), "sr_ME" );  // Because that one is first in the list
+        QCOMPARE( r.language(), "sr_RS" );  // Because that one is first in the list
     }
     {
         auto r = LocaleConfiguration::fromLanguageAndLocation(
@@ -353,6 +380,206 @@ LocaleTests::testLanguageDetectionValencia()
     }
 }
 
+static QStringList
+splitTestFileIntoLines( const QString& filename )
+{
+    // BUILD_AS_TEST is the source-directory path
+    const QFileInfo fi( QString( "%1/tests/%2" ).arg( BUILD_AS_TEST, filename ) );
+    const QString path = fi.absoluteFilePath();
+    QFile testData( path );
+    if ( testData.open( QIODevice::ReadOnly ) )
+    {
+        return QString::fromUtf8( testData.readAll() ).split( '\n', Qt::SkipEmptyParts );
+    }
+    return QStringList {};
+}
+
+void
+LocaleTests::testKDENeonLanguageData()
+{
+    if ( !m_KDEneonLocales.isEmpty() )
+    {
+        return;
+    }
+    const QStringList neonLocales = splitTestFileIntoLines( QStringLiteral( "locale-data-neon" ) );
+    cDebug() << "Loaded KDE neon locales test data" << neonLocales.front() << "to" << neonLocales.back();
+    QCOMPARE( neonLocales.length(), 318 );  // wc -l tells me 318 lines
+    m_KDEneonLocales = neonLocales;
+
+    const QStringList bsdLocales = splitTestFileIntoLines( QStringLiteral( "locale-data-freebsd" ) );
+    cDebug() << "Loaded FreeBSD locales test data" << bsdLocales.front() << "to" << bsdLocales.back();
+    QCOMPARE( bsdLocales.length(), 79 );
+    m_FreeBSDLocales = bsdLocales;
+}
+
+void
+LocaleTests::MappingData()
+{
+    QTest::addColumn< QString >( "selectedLanguage" );
+    QTest::addColumn< QString >( "KDEneonLanguage" );
+    QTest::addColumn< QString >( "FreeBSDLanguage" );
+
+    // Tired of writing QString or QStringLiteral all the time.
+    auto l = []( const char* p ) { return QString::fromUtf8( p ); };
+    auto u = []() { return QString(); };
+
+    // The KDEneon columns include the .UTF-8 from the source data
+    // The FreeBSD columns may have u() to indicate "same as KDEneon",
+    //      that's an empty string.
+    //
+    // Each row shows how a language -- which can be selected from the
+    // welcome page, and is inserted into GS as the language key that
+    // Calamares knows -- should be mapped to a supported system locale.
+    //
+    // All the mappings are for ".. in NL", which can trigger minor variation
+    // if there are languages with a _NL variant (e.g. nl_NL and nl_BE).
+
+    // clang-format off
+    QTest::newRow( "en   " ) << l( "en" )           << l( "en_US.UTF-8" )       << u();
+    QTest::newRow( "en_GB" ) << l( "en_GB" )        << l( "en_GB.UTF-8" )       << u();
+    QTest::newRow( "ca   " ) << l( "ca" )           << l( "ca_ES.UTF-8" )       << u();
+    // FreeBSD has no Valencian variant
+    QTest::newRow( "ca@vl" ) << l( "ca@valencia" )  << l( "ca_ES@valencia" )    << l( "ca_ES.UTF-8" );
+    // FreeBSD has the UTF-8 marker before the @region part
+    QTest::newRow( "sr   " ) << l( "sr" )           << l( "sr_RS" )             << l( "sr_RS.UTF-8" );
+    QTest::newRow( "sr@lt" ) << l( "sr@latin" )     << l( "sr_RS@latin" )       << l( "sr_RS.UTF-8@latin" );
+    QTest::newRow( "pt_PT" ) << l( "pt_PT" )        << l( "pt_PT.UTF-8" )       << u();
+    QTest::newRow( "pt_BR" ) << l( "pt_BR" )        << l( "pt_BR.UTF-8" )       << u();
+    QTest::newRow( "nl   " ) << l( "nl" )           << l( "nl_NL.UTF-8" )       << u();
+    QTest::newRow( "zh_TW" ) << l( "zh_TW" )        << l( "zh_TW.UTF-8" )       << u();
+    // clang-format on
+}
+
+
+void
+LocaleTests::testLanguageMappingNeon_data()
+{
+    MappingData();
+}
+
+void
+LocaleTests::testLanguageMappingFreeBSD_data()
+{
+    MappingData();
+}
+
+void
+LocaleTests::testLanguageMappingNeon()
+{
+    testKDENeonLanguageData();
+    QVERIFY( !m_KDEneonLocales.isEmpty() );
+
+    QFETCH( QString, selectedLanguage );
+    QFETCH( QString, KDEneonLanguage );
+    QFETCH( QString, FreeBSDLanguage );
+
+    QVERIFY( Calamares::Locale::availableLanguages().contains( selectedLanguage ) );
+
+    const auto neon = LocaleConfiguration::fromLanguageAndLocation(
+        ( selectedLanguage ), m_KDEneonLocales, QStringLiteral( "NL" ) );
+    QCOMPARE( neon.language(), KDEneonLanguage );
+}
+
+void
+LocaleTests::testLanguageMappingFreeBSD()
+{
+    testKDENeonLanguageData();
+    QVERIFY( !m_FreeBSDLocales.isEmpty() );
+
+    QFETCH( QString, selectedLanguage );
+    QFETCH( QString, KDEneonLanguage );
+    QFETCH( QString, FreeBSDLanguage );
+
+    QVERIFY( Calamares::Locale::availableLanguages().contains( selectedLanguage ) );
+
+    const auto bsd = LocaleConfiguration::fromLanguageAndLocation(
+        ( selectedLanguage ), m_FreeBSDLocales, QStringLiteral( "NL" ) );
+    const auto expected = FreeBSDLanguage.isEmpty() ? KDEneonLanguage : FreeBSDLanguage;
+    QCOMPARE( bsd.language(), expected );
+}
+
+void
+LocaleTests::testLocaleNameParts()
+{
+    testKDENeonLanguageData();
+    QVERIFY( !m_FreeBSDLocales.isEmpty() );
+    QVERIFY( !m_KDEneonLocales.isEmpty() );
+
+    // Example constant locales
+    {
+        auto c_parts = LocaleNameParts::fromName( QStringLiteral( "nl_NL.UTF-8" ) );
+        QCOMPARE( c_parts.language, QStringLiteral( "nl" ) );
+        QCOMPARE( c_parts.country, QStringLiteral( "NL" ) );
+        QCOMPARE( c_parts.encoding, QStringLiteral( "UTF-8" ) );
+        QVERIFY( c_parts.region.isEmpty() );
+    }
+    {
+        auto c_parts = LocaleNameParts::fromName( QStringLiteral( "C.UTF-8" ) );
+        QCOMPARE( c_parts.language, QStringLiteral( "C" ) );
+        QVERIFY( c_parts.country.isEmpty() );
+        QCOMPARE( c_parts.encoding, QStringLiteral( "UTF-8" ) );
+        QVERIFY( c_parts.region.isEmpty() );
+    }
+
+    // Check all the loaded test locales
+    for ( const auto& s : m_FreeBSDLocales )
+    {
+        auto parts = LocaleNameParts::fromName( s );
+        QVERIFY( parts.isValid() );
+        QCOMPARE( parts.name(), s );
+    }
+
+    for ( const auto& s : m_KDEneonLocales )
+    {
+        auto parts = LocaleNameParts::fromName( s );
+        QVERIFY( parts.isValid() );
+        QCOMPARE( parts.name(), s );
+    }
+}
+
+void
+LocaleTests::testLanguageSimilarity()
+{
+    // Empty
+    {
+        QCOMPARE( LocaleNameParts().similarity( LocaleNameParts() ), 0 );
+    }
+    // Some simple Dutch situations
+    {
+        auto nl_parts = LocaleNameParts::fromName( QStringLiteral( "nl_NL.UTF-8" ) );
+        auto be_parts = LocaleNameParts::fromName( QStringLiteral( "nl_BE.UTF-8" ) );
+        auto nl_short_parts = LocaleNameParts::fromName( QStringLiteral( "nl" ) );
+        QCOMPARE( nl_parts.similarity( nl_parts ), 100 );
+        QCOMPARE( nl_parts.similarity( LocaleNameParts() ), 0 );
+        QCOMPARE( nl_parts.similarity( be_parts ), 80 );  // Language + (empty) region match
+        QCOMPARE( nl_parts.similarity( nl_short_parts ), 90 );
+    }
+
+    // Everything matches itself
+    {
+        if ( m_KDEneonLocales.isEmpty() )
+        {
+            testKDENeonLanguageData();
+        }
+        QVERIFY( !m_FreeBSDLocales.isEmpty() );
+        QVERIFY( !m_KDEneonLocales.isEmpty() );
+        for ( const auto& l : m_KDEneonLocales )
+        {
+            auto locale_name = LocaleNameParts::fromName( l );
+            auto self_similarity = locale_name.similarity( locale_name );
+            if ( self_similarity != 100 )
+            {
+                cDebug() << "Locale" << l << "is unusual.";
+                if ( l == QStringLiteral( "eo" ) )
+                {
+                    QEXPECT_FAIL( "", "Esperanto has no country to match", Continue );
+                }
+            }
+            QCOMPARE( self_similarity, 100 );
+        }
+    }
+}
+
 
 #include "utils/moc-warnings.h"
 
diff --git a/src/modules/locale/tests/locale-data-freebsd b/src/modules/locale/tests/locale-data-freebsd
new file mode 100644
index 0000000000000000000000000000000000000000..281839a90c6efe9e93bbeb0e50f369a70176ac09
--- /dev/null
+++ b/src/modules/locale/tests/locale-data-freebsd
@@ -0,0 +1,79 @@
+C.UTF-8
+af_ZA.UTF-8
+am_ET.UTF-8
+ar_AE.UTF-8
+ar_EG.UTF-8
+ar_JO.UTF-8
+ar_MA.UTF-8
+ar_QA.UTF-8
+ar_SA.UTF-8
+be_BY.UTF-8
+bg_BG.UTF-8
+ca_AD.UTF-8
+ca_ES.UTF-8
+ca_FR.UTF-8
+ca_IT.UTF-8
+cs_CZ.UTF-8
+da_DK.UTF-8
+de_AT.UTF-8
+de_CH.UTF-8
+de_DE.UTF-8
+el_GR.UTF-8
+en_AU.UTF-8
+en_CA.UTF-8
+en_GB.UTF-8
+en_HK.UTF-8
+en_IE.UTF-8
+en_NZ.UTF-8
+en_PH.UTF-8
+en_SG.UTF-8
+en_US.UTF-8
+en_ZA.UTF-8
+es_AR.UTF-8
+es_CR.UTF-8
+es_ES.UTF-8
+es_MX.UTF-8
+et_EE.UTF-8
+eu_ES.UTF-8
+fi_FI.UTF-8
+fr_BE.UTF-8
+fr_CA.UTF-8
+fr_CH.UTF-8
+fr_FR.UTF-8
+ga_IE.UTF-8
+he_IL.UTF-8
+hi_IN.UTF-8
+hr_HR.UTF-8
+hu_HU.UTF-8
+hy_AM.UTF-8
+is_IS.UTF-8
+it_CH.UTF-8
+it_IT.UTF-8
+ja_JP.UTF-8
+kk_KZ.UTF-8
+ko_KR.UTF-8
+lt_LT.UTF-8
+lv_LV.UTF-8
+mn_MN.UTF-8
+nb_NO.UTF-8
+nl_BE.UTF-8
+nl_NL.UTF-8
+nn_NO.UTF-8
+pl_PL.UTF-8
+pt_BR.UTF-8
+pt_PT.UTF-8
+ro_RO.UTF-8
+ru_RU.UTF-8
+se_FI.UTF-8
+se_NO.UTF-8
+sk_SK.UTF-8
+sl_SI.UTF-8
+sr_RS.UTF-8
+sr_RS.UTF-8@latin
+sv_FI.UTF-8
+sv_SE.UTF-8
+tr_TR.UTF-8
+uk_UA.UTF-8
+zh_CN.UTF-8
+zh_HK.UTF-8
+zh_TW.UTF-8
diff --git a/src/modules/locale/tests/locale-data-neon b/src/modules/locale/tests/locale-data-neon
new file mode 100644
index 0000000000000000000000000000000000000000..0f0254d013b2499f401129dd3b7b380468bde5d2
--- /dev/null
+++ b/src/modules/locale/tests/locale-data-neon
@@ -0,0 +1,318 @@
+aa_DJ.UTF-8
+aa_ER
+aa_ER@saaho
+aa_ET
+af_ZA.UTF-8
+agr_PE
+ak_GH
+am_ET
+an_ES.UTF-8
+anp_IN
+ar_AE.UTF-8
+ar_BH.UTF-8
+ar_DZ.UTF-8
+ar_EG.UTF-8
+ar_IN
+ar_IQ.UTF-8
+ar_JO.UTF-8
+ar_KW.UTF-8
+ar_LB.UTF-8
+ar_LY.UTF-8
+ar_MA.UTF-8
+ar_OM.UTF-8
+ar_QA.UTF-8
+ar_SA.UTF-8
+ar_SD.UTF-8
+ar_SS
+ar_SY.UTF-8
+ar_TN.UTF-8
+ar_YE.UTF-8
+ayc_PE
+az_AZ
+az_IR
+as_IN
+ast_ES.UTF-8
+be_BY.UTF-8
+be_BY@latin
+bem_ZM
+ber_DZ
+ber_MA
+bg_BG.UTF-8
+bhb_IN.UTF-8
+bho_IN
+bho_NP
+bi_VU
+bn_BD
+bn_IN
+bo_CN
+bo_IN
+br_FR.UTF-8
+brx_IN
+bs_BA.UTF-8
+byn_ER
+ca_AD.UTF-8
+ca_ES.UTF-8
+ca_ES@valencia
+ca_FR.UTF-8
+ca_IT.UTF-8
+ce_RU
+ckb_IQ
+chr_US
+cmn_TW
+crh_UA
+cs_CZ.UTF-8
+csb_PL
+cv_RU
+cy_GB.UTF-8
+da_DK.UTF-8
+de_AT.UTF-8
+de_BE.UTF-8
+de_CH.UTF-8
+de_DE.UTF-8
+de_IT.UTF-8
+de_LI.UTF-8
+de_LU.UTF-8
+doi_IN
+dsb_DE
+dv_MV
+dz_BT
+el_GR.UTF-8
+el_CY.UTF-8
+en_AG
+en_AU.UTF-8
+en_BW.UTF-8
+en_CA.UTF-8
+en_DK.UTF-8
+en_GB.UTF-8
+en_HK.UTF-8
+en_IE.UTF-8
+en_IL
+en_IN
+en_NG
+en_NZ.UTF-8
+en_PH.UTF-8
+en_SC.UTF-8
+en_SG.UTF-8
+en_US.UTF-8
+en_ZA.UTF-8
+en_ZM
+en_ZW.UTF-8
+eo
+eo_US.UTF-8
+es_AR.UTF-8
+es_BO.UTF-8
+es_CL.UTF-8
+es_CO.UTF-8
+es_CR.UTF-8
+es_CU
+es_DO.UTF-8
+es_EC.UTF-8
+es_ES.UTF-8
+es_GT.UTF-8
+es_HN.UTF-8
+es_MX.UTF-8
+es_NI.UTF-8
+es_PA.UTF-8
+es_PE.UTF-8
+es_PR.UTF-8
+es_PY.UTF-8
+es_SV.UTF-8
+es_US.UTF-8
+es_UY.UTF-8
+es_VE.UTF-8
+et_EE.UTF-8
+eu_ES.UTF-8
+eu_FR.UTF-8
+fa_IR
+ff_SN
+fi_FI.UTF-8
+fil_PH
+fo_FO.UTF-8
+fr_BE.UTF-8
+fr_CA.UTF-8
+fr_CH.UTF-8
+fr_FR.UTF-8
+fr_LU.UTF-8
+fur_IT
+fy_NL
+fy_DE
+ga_IE.UTF-8
+gd_GB.UTF-8
+gez_ER
+gez_ER@abegede
+gez_ET
+gez_ET@abegede
+gl_ES.UTF-8
+gu_IN
+gv_GB.UTF-8
+ha_NG
+hak_TW
+he_IL.UTF-8
+hi_IN
+hif_FJ
+hne_IN
+hr_HR.UTF-8
+hsb_DE.UTF-8
+ht_HT
+hu_HU.UTF-8
+hy_AM
+ia_FR
+id_ID.UTF-8
+ig_NG
+ik_CA
+is_IS.UTF-8
+it_CH.UTF-8
+it_IT.UTF-8
+iu_CA
+ja_JP.UTF-8
+ka_GE.UTF-8
+kab_DZ
+kk_KZ.UTF-8
+kl_GL.UTF-8
+km_KH
+kn_IN
+ko_KR.UTF-8
+kok_IN
+ks_IN
+ks_IN@devanagari
+ku_TR.UTF-8
+kw_GB.UTF-8
+ky_KG
+lb_LU
+lg_UG.UTF-8
+li_BE
+li_NL
+lij_IT
+ln_CD
+lo_LA
+lt_LT.UTF-8
+lv_LV.UTF-8
+lzh_TW
+mag_IN
+mai_IN
+mai_NP
+mfe_MU
+mg_MG.UTF-8
+mhr_RU
+mi_NZ.UTF-8
+miq_NI
+mjw_IN
+mk_MK.UTF-8
+ml_IN
+mn_MN
+mni_IN
+mnw_MM
+mr_IN
+ms_MY.UTF-8
+mt_MT.UTF-8
+my_MM
+nan_TW
+nan_TW@latin
+nb_NO.UTF-8
+nds_DE
+nds_NL
+ne_NP
+nhn_MX
+niu_NU
+niu_NZ
+nl_AW
+nl_BE.UTF-8
+nl_NL.UTF-8
+nn_NO.UTF-8
+nr_ZA
+nso_ZA
+oc_FR.UTF-8
+om_ET
+om_KE.UTF-8
+or_IN
+os_RU
+pa_IN
+pa_PK
+pap_AW
+pap_CW
+pl_PL.UTF-8
+ps_AF
+pt_BR.UTF-8
+pt_PT.UTF-8
+quz_PE
+raj_IN
+ro_RO.UTF-8
+ru_RU.UTF-8
+ru_UA.UTF-8
+rw_RW
+sa_IN
+sah_RU
+sat_IN
+sc_IT
+sd_IN
+sd_IN@devanagari
+sd_PK
+se_NO
+sgs_LT
+shn_MM
+shs_CA
+si_LK
+sid_ET
+sk_SK.UTF-8
+sl_SI.UTF-8
+sm_WS
+so_DJ.UTF-8
+so_ET
+so_KE.UTF-8
+so_SO.UTF-8
+sq_AL.UTF-8
+sq_MK
+sr_ME
+sr_RS
+sr_RS@latin
+ss_ZA
+st_ZA.UTF-8
+sv_FI.UTF-8
+sv_SE.UTF-8
+sw_KE
+sw_TZ
+szl_PL
+ta_IN
+ta_LK
+tcy_IN.UTF-8
+te_IN
+tg_TJ.UTF-8
+th_TH.UTF-8
+the_NP
+ti_ER
+ti_ET
+tig_ER
+tk_TM
+tl_PH.UTF-8
+tn_ZA
+to_TO
+tpi_PG
+tr_CY.UTF-8
+tr_TR.UTF-8
+ts_ZA
+tt_RU
+tt_RU@iqtelif
+ug_CN
+ug_CN@latin
+uk_UA.UTF-8
+unm_US
+ur_IN
+ur_PK
+uz_UZ.UTF-8
+uz_UZ@cyrillic
+ve_ZA
+vi_VN
+wa_BE.UTF-8
+wae_CH
+wal_ET
+wo_SN
+xh_ZA.UTF-8
+yi_US.UTF-8
+yo_NG
+yue_HK
+yuw_PG
+zh_CN.UTF-8
+zh_HK.UTF-8
+zh_SG.UTF-8
+zh_TW.UTF-8
+zu_ZA.UTF-8
diff --git a/src/modules/localeq/CMakeLists.txt b/src/modules/localeq/CMakeLists.txt
index f086676e637d64564a898b8a836b6773cdc7c3e9..b8ae6a9333b0a708e20633efc0edd3a333b3d8cb 100644
--- a/src/modules/localeq/CMakeLists.txt
+++ b/src/modules/localeq/CMakeLists.txt
@@ -31,8 +31,9 @@ calamares_add_plugin(localeq
     EXPORT_MACRO PLUGINDLLEXPORT_PRO
     SOURCES
         LocaleQmlViewStep.cpp
-        ${_locale}/LocaleConfiguration.cpp
         ${_locale}/Config.cpp
+        ${_locale}/LocaleConfiguration.cpp
+        ${_locale}/LocaleNames.cpp
         ${_locale}/SetTimezoneJob.cpp
     RESOURCES
         localeq.qrc