在 default_script.csv 和 sublanguages.csv 中记录数据来源 (#7049)

This commit is contained in:
Glavo authored and GitHub committed 2026-10-24 14:14:24 +08:00
1 parent 8954139b5e
commit a4db0532b9
5 files changed
+233 -49

No files matched your search

@@ -1,3 +1,5 @@
# IANA registry version: 2026-09-17
# Generated from https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
Arab,ar,fa,ps,ur
Armn,hy
Beng,as,bn
1 Arab,ar,fa,ps,ur # IANA registry version: 2026-09-17
1 # IANA registry version: 2026-09-17
2 # Generated from https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
3 Arab,ar,fa,ps,ur Arab,ar,fa,ps,ur
4 Armn,hy Armn,hy
5 Beng,as,bn Beng,as,bn
@@ -1,3 +1,5 @@
# IANA registry version: 2026-09-17
# Generated from https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
ak,tw,fat
ar,aao,abh,abv,acm,acq,acw,acx,acy,adf,aeb,aec,afb,apc,apd,arb,arq,ars,ary,arz,auz,avl,ayh,ayl,ayn,ayp,pga,shu,ssh
ay,ayc,ayr
1 ak,tw,fat # IANA registry version: 2026-09-17
1 # IANA registry version: 2026-09-17
2 # Generated from https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry
3 ak,tw,fat ak,tw,fat
4 ar,aao,abh,abv,acm,acq,acw,acx,acy,adf,aeb,aec,afb,apc,apd,arb,arq,ars,ary,arz,auz,avl,ayh,ayl,ayn,ayp,pga,shu,ssh ar,aao,abh,abv,acm,acq,acw,acx,acy,adf,aeb,aec,afb,apc,apd,arb,arq,ars,ary,arz,auz,avl,ayh,ayl,ayn,ayp,pga,shu,ssh
5 ay,ayc,ayr ay,ayc,ayr
+3 -3
View File
@@ -1,6 +1,6 @@
import org.jackhuang.hmcl.gradle.ci.CheckUpdate
import org.jackhuang.hmcl.gradle.docs.UpdateDocuments
import org.jackhuang.hmcl.gradle.l10n.ParseLanguageSubtagRegistry
import org.jackhuang.hmcl.gradle.l10n.UpdateLanguageSubtagRegistry
import org.jackhuang.hmcl.gradle.l10n.UpdateTimeZoneRegions
plugins {
@@ -81,8 +81,8 @@ org.jackhuang.hmcl.gradle.javafx.JavaFXUtils.register(rootProject)
defaultTasks("clean", "build")
tasks.register<ParseLanguageSubtagRegistry>("parseLanguageSubtagRegistry") {
languageSubtagRegistryFile.set(layout.projectDirectory.file("language-subtag-registry"))
tasks.register<UpdateLanguageSubtagRegistry>("updateLanguageSubtagRegistry") {
description = "Update the checked-in IANA sublanguage and default-script mappings"
sublanguagesFile.set(layout.projectDirectory.file("HMCLCore/src/main/resources/assets/lang/sublanguages.csv"))
defaultScriptFile.set(layout.projectDirectory.file("HMCLCore/src/main/resources/assets/lang/default_script.csv"))
@@ -20,43 +20,86 @@ package org.jackhuang.hmcl.gradle.l10n;
import org.gradle.api.DefaultTask;
import org.gradle.api.GradleException;
import org.gradle.api.file.RegularFileProperty;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.OutputFile;
import org.gradle.api.tasks.TaskAction;
import org.jetbrains.annotations.NotNull;
import org.gradle.work.DisableCachingByDefault;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.StringReader;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/// Updates the checked-in sublanguage and default-script mappings from the IANA Language Subtag Registry.
///
/// @author Glavo
/// @see [language-subtag-registry](https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry)
public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
@NotNullByDefault
@DisableCachingByDefault(because = "Downloads IANA data to update checked-in resources")
public abstract class UpdateLanguageSubtagRegistry extends DefaultTask {
@InputFile
public abstract RegularFileProperty getLanguageSubtagRegistryFile();
/// Identifies the current registry published by IANA.
private static final URI SOURCE = URI.create("https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry");
/// Creates an updater that checks the remote registry on every invocation.
public UpdateLanguageSubtagRegistry() {
getOutputs().upToDateWhen(task -> false);
}
/// Returns the CSV destination mapping macrolanguages to their non-deprecated sublanguages.
@OutputFile
public abstract RegularFileProperty getSublanguagesFile();
/// CSV file storing the mapping from subtag to their default scripts.
/// Returns the CSV destination grouping subtags by their default scripts.
@OutputFile
public abstract RegularFileProperty getDefaultScriptFile();
/// Downloads and parses the registry before replacing the two UTF-8 CSV resources.
///
/// @throws IOException if downloading or writing fails; an earlier output may already have been replaced
/// @throws GradleException if the registry is malformed, has no records, or has no valid file date
@TaskAction
public void run() throws IOException {
List<Item> items;
update(readSource());
}
try (var reader = Files.newBufferedReader(getLanguageSubtagRegistryFile().getAsFile().get().toPath())) {
var builder = new ItemsBuilder();
builder.parse(reader);
items = builder.items;
/// Downloads the UTF-8 registry using bounded connection and read timeouts.
///
/// @throws IOException if downloading fails
private static String readSource() throws IOException {
var connection = SOURCE.toURL().openConnection();
connection.setConnectTimeout(15_000);
connection.setReadTimeout(30_000);
try (var input = connection.getInputStream()) {
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
}
}
/// Parses a registry snapshot and writes sorted mappings with its file date and source URI.
/// Retains the Traditional Chinese default for Classical Chinese and infers a variant script
/// only when all its prefixes explicitly specify the same script.
///
/// @throws IOException if reading or writing fails
/// @throws GradleException if the registry is malformed, has no records, or has no valid file date
void update(String source) throws IOException {
var builder = new ItemsBuilder();
try (var reader = new BufferedReader(new StringReader(source))) {
builder.parse(reader);
}
if (builder.items.isEmpty() || builder.fileDate == null) {
throw new GradleException("Registry must contain a File-Date and at least one record");
}
String header = "# IANA registry version: " + builder.fileDate + "\n"
+ "# Generated from " + SOURCE + "\n";
List<Item> items = builder.items;
MultiMap scriptToSubtag = new MultiMap();
MultiMap languageToSub = new MultiMap();
@@ -83,7 +126,7 @@ public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
}
case "variant" -> {
List<String> prefixes = item.allValues("Prefix");
String defaultScript = null;
@Nullable String defaultScript = null;
for (String prefix : prefixes) {
String script = Locale.forLanguageTag(prefix).getScript();
if (script.isEmpty()) {
@@ -110,62 +153,62 @@ public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
}
}
languageToSub.saveToCSV(getSublanguagesFile());
scriptToSubtag.saveToCSV(getDefaultScriptFile());
languageToSub.saveToCSV(getSublanguagesFile(), header);
scriptToSubtag.saveToCSV(getDefaultScriptFile(), header);
}
/// Groups unique tags under keys in deterministic order.
@NotNullByDefault
private static final class MultiMap {
/// Stores sorted keys and sorted, deduplicated values.
private final TreeMap<String, Set<String>> allValues = new TreeMap<>(TAG_COMPARATOR);
/// Adds a value, ignoring duplicates for the same key.
void add(String key, String value) {
allValues.computeIfAbsent(key, k -> new TreeSet<>(TAG_COMPARATOR)).add(value);
}
void saveToCSV(RegularFileProperty csvFile) throws IOException {
try (var writer = Files.newBufferedWriter(csvFile.getAsFile().get().toPath(),
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING)) {
for (Map.Entry<String, Set<String>> entry : allValues.entrySet()) {
String key = entry.getKey();
Set<String> values = entry.getValue();
writer.write(key);
for (String value : values) {
writer.write(',');
writer.write(value);
}
writer.newLine();
}
}
/// Replaces the UTF-8 CSV with the source header and deterministically sorted rows.
///
/// @throws IOException if writing fails
void saveToCSV(RegularFileProperty csvFile, String header) throws IOException {
var csv = new StringBuilder(header);
allValues.forEach((key, values) -> {
csv.append(key);
values.forEach(value -> csv.append(',').append(value));
csv.append('\n');
});
Files.writeString(csvFile.get().getAsFile().toPath(), csv);
}
}
/// Stores repeated registry fields in encounter order.
@NotNullByDefault
private static final class Item {
/// Maps field names to their values in encounter order.
final Map<String, List<String>> values = new LinkedHashMap<>();
public @NotNull List<String> allValues(String name) {
/// Returns the stored values, or an empty list when the field is absent.
public List<String> allValues(String name) {
return values.getOrDefault(name, List.of());
}
public @NotNull Optional<String> firstValue(String name) {
/// Returns the first field value, if present.
public Optional<String> firstValue(String name) {
return Optional.ofNullable(values.get(name)).map(it -> it.get(0));
}
public @Nullable String firstValueOrNull(String name) {
return firstValue(name).orElse(null);
}
public @NotNull String firstValueOrThrow(String name) {
/// Returns the first field value, throwing [GradleException] when absent.
public String firstValueOrThrow(String name) {
return firstValue(name).orElseThrow(() -> new GradleException("No value found for " + name + " in " + this));
}
/// Appends a field value.
public void put(String name, String value) {
values.computeIfAbsent(name, ignored -> new ArrayList<>(1)).add(value);
}
/// Returns the fields in registry syntax for diagnostics.
@Override
public String toString() {
StringJoiner joiner = new StringJoiner("\n");
@@ -180,21 +223,31 @@ public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
}
}
/// Parses registry records, unfolding continuation lines and retaining the header date.
@NotNullByDefault
private static final class ItemsBuilder {
/// Contains completed records, excluding the registry header.
private final List<Item> items = new ArrayList<>(1024);
/// Accumulates the current record.
private Item current = new Item();
private String currentName = null;
private String currentValue = null;
/// Holds the pending field name, or null between fields.
private @Nullable String currentName = null;
/// Holds the pending unfolded value, or null between fields.
private @Nullable String currentValue = null;
/// Holds the validated registry date, or null before the header is parsed.
private @Nullable String fileDate;
/// Commits the pending field to the current record.
private void updateCurrent() {
if (currentName != null) {
current.put(currentName, currentValue);
current.put(currentName, Objects.requireNonNull(currentValue));
currentName = null;
currentValue = null;
}
}
private void updateItems() throws IOException {
/// Commits a record or validates and stores the registry header date.
private void updateItems() {
updateCurrent();
if (current.values.isEmpty())
@@ -202,6 +255,16 @@ public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
if (current.firstValue("Type").isEmpty()) {
if (current.firstValue("File-Date").isPresent()) {
String date = current.firstValueOrThrow("File-Date");
try {
LocalDate.parse(date);
} catch (DateTimeParseException e) {
throw new GradleException("Invalid registry File-Date: " + date, e);
}
if (fileDate != null || !items.isEmpty()) {
throw new GradleException("Unexpected registry header");
}
fileDate = date;
current.values.clear();
return;
} else {
@@ -213,10 +276,14 @@ public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
current = new Item();
}
/// Reads all records without closing the supplied reader.
///
/// @throws IOException if reading fails
/// @throws GradleException if a line or record is malformed
void parse(BufferedReader reader) throws IOException {
Pattern linePattern = Pattern.compile("^(?<name>[A-Za-z\\-]+): (?<value>.*)$");
String line;
@Nullable String line;
while ((line = reader.readLine()) != null) {
if (line.isBlank()) {
continue;
@@ -245,6 +312,7 @@ public abstract class ParseLanguageSubtagRegistry extends DefaultTask {
}
}
/// Orders tags by length, then by their case-sensitive lexical order.
private static final Comparator<String> TAG_COMPARATOR = (lang1, lang2) -> {
if (lang1.length() != lang2.length())
return Integer.compare(lang1.length(), lang2.length());
@@ -0,0 +1,112 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> and contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.gradle.l10n;
import org.gradle.api.GradleException;
import org.gradle.testfixtures.ProjectBuilder;
import org.jetbrains.annotations.NotNullByDefault;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/// Verifies source metadata, mapping rules, and preservation of resources on invalid input.
@NotNullByDefault
final class UpdateLanguageSubtagRegistryTest {
/// Holds isolated output files for each test.
@TempDir
Path directory;
/// Creates an updater whose resources are confined to the test directory.
private UpdateLanguageSubtagRegistry createTask() {
var task = ProjectBuilder.builder().build().getTasks()
.register("updateLanguageSubtagRegistry", UpdateLanguageSubtagRegistry.class).get();
task.getSublanguagesFile().set(directory.resolve("sublanguages.csv").toFile());
task.getDefaultScriptFile().set(directory.resolve("default_script.csv").toFile());
return task;
}
/// Preserves filtering, deduplication, variant inference, and the Classical Chinese override.
@Test
void updatesMappings() throws IOException {
createTask().update("""
File-Date: 2026-10-01
%%
Type: language
Subtag: abc
Description: First line
continuation
Macrolanguage: aa
Suppress-Script: Latn
%%
Type: extlang
Subtag: abc
Macrolanguage: aa
%%
Type: language
Subtag: old
Macrolanguage: aa
Deprecated: 2020-01-01
%%
Type: variant
Subtag: example
Prefix: aa-Latn
Prefix: bb-Latn
%%
Type: variant
Subtag: mixed
Prefix: aa-Latn
Prefix: bb-Cyrl
%%
Type: variant
Subtag: implicit
Prefix: aa
%%
Type: grandfathered
Tag: i-example
""");
String header = "# IANA registry version: 2026-10-01\n"
+ "# Generated from https://www.iana.org/assignments/language-subtag-registry/language-subtag-registry\n";
assertEquals(header + "aa,abc\n", Files.readString(directory.resolve("sublanguages.csv")));
assertEquals(header + "Hant,lzh\nLatn,abc,example\n", Files.readString(directory.resolve("default_script.csv")));
}
/// Rejects missing metadata, invalid dates, empty registries, and malformed records before writing.
@ParameterizedTest
@ValueSource(strings = {
"", "File-Date: 2026-10-01\n",
"Type: language\nSubtag: abc\n",
"File-Date: 2026-02-30\n%%\nType: language\nSubtag: abc\n",
"File-Date: 2026-10-01\n%%\ninvalid",
"File-Date: 2026-10-01\n%%\nType: language\n"
})
void preservesOutputsOnInvalidInput(String registry) throws IOException {
var task = createTask();
Files.writeString(directory.resolve("sublanguages.csv"), "original");
Files.writeString(directory.resolve("default_script.csv"), "original");
assertThrows(GradleException.class, () -> task.update(registry));
assertEquals("original", Files.readString(directory.resolve("sublanguages.csv")));
assertEquals("original", Files.readString(directory.resolve("default_script.csv")));
}
}