fix(dictionary): no language goes missing while the curator updates

The update removed the files of sources no longer wanted first, Korean's
words from Wiktionary among them, before KRDict's replacement was
written, which on the first run after v0.16.0 came after Wiktionary's
3 GB conversion: the server unloaded Korean meanwhile, and for good if
KRDict failed. Removal now comes last, and a replaced source stays until
its replacement is there. A bare curate update, without --languages,
no longer removes every other language. A KRDict export of another
shape (the page's buttons reordered) is refused rather than written as
a dictionary of no words over Korean's. The server never unloads from
an empty dictionaries folder (a volume made anew), and a failed unload
no longer stops the files from loading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 17:36:55 +02:00
commit d5fe51b296
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
6 changed files with 84 additions and 12 deletions

View file

@ -196,6 +196,9 @@ Future<void> main(List<String> args) async {
Directory(options.option('data')!), Directory(options.option('data')!),
days, days,
languages: _languages(options.option('languages')), languages: _languages(options.option('languages')),
// Without --languages, the files of other languages are left as
// they are: an operator may keep them up to date otherwise.
removesOthers: options.option('languages') != null,
); );
return; return;
} }
@ -536,6 +539,7 @@ Future<void> _update(
Directory data, Directory data,
int? every, { int? every, {
Set<String> languages = const {}, Set<String> languages = const {},
bool removesOthers = true,
}) async { }) async {
final (:wiktionary, :chinese, :korean, :greek) = dictionarySources(languages); final (:wiktionary, :chinese, :korean, :greek) = dictionarySources(languages);
void say(String message) => void say(String message) =>
@ -599,17 +603,22 @@ Future<void> _update(
), ),
); );
// In this order: Korean's words and Hanja take what Wiktionary kept. // In this order: Korean's words and Hanja take what Wiktionary kept.
// What is no longer wanted goes last, once what replaces it is there.
Future<bool> languagesOnce() async { Future<bool> languagesOnce() async {
final removed = removeUnwanted(into, languages); final done =
!(!await chineseOnce() |
!await wiktionaryOnce() |
!await koreanOnce() |
!await charactersOnce());
final removed = removesOthers
? removeUnwanted(into, languages)
: const <File>[];
if (removed.isNotEmpty) { if (removed.isNotEmpty) {
say( say(
'Removed ${[for (final file in removed) file.uri.pathSegments.last].join(', ')}.', 'Removed ${[for (final file in removed) file.uri.pathSegments.last].join(', ')}.',
); );
} }
return !(!await chineseOnce() | return done;
!await wiktionaryOnce() |
!await koreanOnce() |
!await charactersOnce());
} }
if (every == null) { if (every == null) {

View file

@ -297,6 +297,9 @@ String? krdictJsonDownloadOf(String page) {
if (file.isFile && file.name.endsWith('.json')) file, if (file.isFile && file.name.endsWith('.json')) file,
]..sort((a, b) => numberOf(a).compareTo(numberOf(b))); ]..sort((a, b) => numberOf(a).compareTo(numberOf(b)));
Stream<Object?> entries() async* { Stream<Object?> entries() async* {
// An export of another shape (the page's buttons reordered, say) would
// otherwise be written as a dictionary of no words over Korean's.
var count = 0;
for (final file in files) { for (final file in files) {
final bytes = file.content; final bytes = file.content;
Stream<List<int>> chunks() async* { Stream<List<int>> chunks() async* {
@ -309,12 +312,18 @@ String? krdictJsonDownloadOf(String page) {
} }
} }
yield* jsonArrayElements( await for (final entry in jsonArrayElements(
utf8.decoder.bind(chunks()), utf8.decoder.bind(chunks()),
key: 'LexicalEntry', key: 'LexicalEntry',
); )) {
count++;
yield entry;
}
file.clear(); file.clear();
} }
if (count == 0) {
throw const FormatException("KRDict's export holds no entries.");
}
} }
return ( return (

View file

@ -447,7 +447,10 @@ Set<String> wantedFiles(Set<String> languages) {
/// Removes from [into] the dictionary files the curator writes that /// Removes from [into] the dictionary files the curator writes that
/// [languages] no longer want ([wantedFiles]), so the server unloads them; /// [languages] no longer want ([wantedFiles]), so the server unloads them;
/// files of others (an operator's own) stay. Returns the files removed. /// files of others (an operator's own) stay. A source another replaces
/// ([_replacedBy]: Korean's words from Wiktionary by KRDict's) stays until
/// its replacement is there, so the language never goes missing between.
/// Returns the files removed.
List<File> removeUnwanted(Directory into, Set<String> languages) { List<File> removeUnwanted(Directory into, Set<String> languages) {
if (!into.existsSync()) { if (!into.existsSync()) {
return const []; return const [];
@ -456,6 +459,7 @@ List<File> removeUnwanted(Directory into, Set<String> languages) {
final removed = <File>[]; final removed = <File>[];
for (final entity in into.listSync()) { for (final entity in into.listSync()) {
final name = entity.uri.pathSegments.last; final name = entity.uri.pathSegments.last;
final replacement = _replacedBy[name];
if (entity is File && if (entity is File &&
name.endsWith('.jsonl.gz') && name.endsWith('.jsonl.gz') &&
const [ const [
@ -465,7 +469,10 @@ List<File> removeUnwanted(Directory into, Set<String> languages) {
'krdict-', 'krdict-',
'greek-alphabet-', 'greek-alphabet-',
].any(name.startsWith) && ].any(name.startsWith) &&
!wanted.contains(name)) { !wanted.contains(name) &&
!(replacement != null &&
wanted.contains(replacement) &&
!File('${into.path}/$replacement').existsSync())) {
entity.deleteSync(); entity.deleteSync();
removed.add(entity); removed.add(entity);
} }
@ -473,6 +480,9 @@ List<File> removeUnwanted(Directory into, Set<String> languages) {
return removed; return removed;
} }
/// The files of sources another replaced, and the replacement's.
const _replacedBy = {'wiktionary-ko-en.jsonl.gz': krdictFileName};
/// Tatoeba's weekly export of a language's sentences ([code], ISO 639-3). /// Tatoeba's weekly export of a language's sentences ([code], ISO 639-3).
Uri tatoebaSentencesOf(String code) => Uri.parse( Uri tatoebaSentencesOf(String code) => Uri.parse(
'https://downloads.tatoeba.org/exports/per_language/$code/' 'https://downloads.tatoeba.org/exports/per_language/$code/'

View file

@ -34,6 +34,27 @@ void main() {
]; ];
} }
test('an export of another shape is refused rather than written as a '
'dictionary of no words', () async {
for (final files in [
<ArchiveFile>[],
[ArchiveFile.bytes('1_5000_20260919.xml', utf8.encode('<xml/>'))],
[
ArchiveFile.bytes(
'1_5000_20260919.json',
utf8.encode(jsonEncode({'LexicalResource': <String, Object?>{}})),
),
],
]) {
final archive = Archive();
files.forEach(archive.add);
final zip = ZipEncoder().encodeBytes(archive);
final (version: _, :entries) = krdictExport(zip);
await expectLater(entries.toList(), throwsFormatException);
}
});
test("KRDict's words with their level, pronunciation where it differs from " test("KRDict's words with their level, pronunciation where it differs from "
'the spelling, Hanja, forms and English senses', () async { 'the spelling, Hanja, forms and English senses', () async {
final [header, ...entries] = await converted(); final [header, ...entries] = await converted();

View file

@ -321,6 +321,22 @@ void main() {
); );
}); });
test("a replaced source stays until what replaces it is there: Korean's "
'words from Wiktionary until KRDict\'s are', () {
File('${into.path}/wiktionary-ko-en.jsonl.gz').writeAsStringSync('');
expect(removeUnwanted(into, {'ko'}), isEmpty);
File('${into.path}/krdict-ko-en.jsonl.gz').writeAsStringSync('');
expect(
[
for (final file in removeUnwanted(into, {'ko'}))
file.uri.pathSegments.last,
],
['wiktionary-ko-en.jsonl.gz'],
);
});
group("KRDict's export", () { group("KRDict's export", () {
var number = '217'; var number = '217';
var downloads = 0; var downloads = 0;

View file

@ -77,9 +77,16 @@ Future<void> _loadFolder(Session session, Directory directory) async {
// A dictionary whose file is gone goes too (a language the instance no // A dictionary whose file is gone goes too (a language the instance no
// longer keeps, a source the curator replaced: Korean's words from // longer keeps, a source the curator replaced: Korean's words from
// Wiktionary before KRDict); only when every file could be read, as an // Wiktionary before KRDict); only when every file could be read, as an
// unreadable one may be the gone dictionary's. // unreadable one may be the gone dictionary's, and never for a folder
if (!unreadable) { // with none, as a volume made anew or a mount gone wrong leaves it:
// that would unload every dictionary. Failing, it leaves the files to
// load all the same.
if (!unreadable && files.isNotEmpty) {
try {
await _unloadGone(session, held); await _unloadGone(session, held);
} on Object catch (error, st) {
failed('Unloading dictionaries whose file is gone failed.', error, st);
}
} }
for (final MapEntry(key: source, value: files) in bySource.entries) { for (final MapEntry(key: source, value: files) in bySource.entries) {
if (files.length > 1) { if (files.length > 1) {