feat: dictionaries hold words or characters, and are asked by their source

A dictionary source now says what it holds (`kind`: `words`, the default
for files from before, or `characters`), a string so that an app older
than a new kind leaves it out. Search, countLabelled and labelled take the
dictionary by its source and meaning language, as the unique index names
it, rather than by the language: with KANJIDIC2 beside JMdict, a search
for words would otherwise bring kanji, and JLPT N5 would hold both. The
search reads the language for its query variants from the source's row;
a dictionary the instance doesn't hold finds nothing. The rank index goes
by source too.

Migration: drops and recreates dictionary_entry_rank_idx on (source,
glossLanguage, rank), and adds dictionary_source.kind, text NOT NULL
DEFAULT 'words', so a JMdict loaded before is a dictionary of words. Both
tables are reference data, never synced; the old server keeps working
until the switch (its queries by language just lose the index).

In the app, a studyset's dictionary is one of words unless a kind is
asked for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 14:47:46 +02:00
commit a6699b30cb
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 11756 additions and 84 deletions

View file

@ -16,17 +16,21 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
Future<List<Dictionary>> dictionaries() async =>
_known ??= await _gateway.dictionaries();
/// The dictionary for a studyset in [languages]: of its front's
/// language, with meanings in its back's, else in English, else in any;
/// null when there is none, or no front language.
Future<Dictionary?> dictionaryFor(StudysetLanguages languages) async {
/// The dictionary of [kind] (words unless given) for a studyset in
/// [languages]: of its front's language, with meanings in its back's,
/// else in English, else in any; null when there is none, or no front
/// language.
Future<Dictionary?> dictionaryFor(
StudysetLanguages languages, {
DictionaryKind kind = DictionaryKind.words,
}) async {
final front = languages.front;
if (front == null) {
return null;
}
final ofLanguage = [
for (final dictionary in await dictionaries())
if (dictionary.language == front) dictionary,
if (dictionary.language == front && dictionary.kind == kind) dictionary,
];
return ofLanguage
.where((dictionary) => dictionary.glossLanguage == languages.back)

View file

@ -5,26 +5,30 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.da
/// [DictionaryGateway] asking the instance through the Serverpod client.
class ServerpodDictionaryGateway(final protocol.Client _client)
implements DictionaryGateway {
/// The dictionaries, but those of a kind this app doesn't know (a newer
/// instance's).
@override
Future<List<Dictionary>> dictionaries() async => [
for (final source in await _client.dictionary.dictionaries())
Dictionary(
source: source.source,
sourceVersion: source.sourceVersion,
language: source.language,
glossLanguage: source.glossLanguage,
license: source.license,
credits: [
for (final credit in source.credits)
(
name: credit.name,
by: credit.by,
license: credit.license,
url: credit.url,
),
],
entryCount: source.entryCount,
),
if (DictionaryKind.values.asNameMap()[source.kind] case final kind?)
Dictionary(
source: source.source,
sourceVersion: source.sourceVersion,
kind: kind,
language: source.language,
glossLanguage: source.glossLanguage,
license: source.license,
credits: [
for (final credit in source.credits)
(
name: credit.name,
by: credit.by,
license: credit.license,
url: credit.url,
),
],
entryCount: source.entryCount,
),
];
@override
@ -33,7 +37,7 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
String query,
) async => [
for (final hit in await _client.dictionary.search(
dictionary.language,
dictionary.source,
dictionary.glossLanguage,
query,
))
@ -43,7 +47,7 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
@override
Future<int> countLabelled(Dictionary dictionary, String label) =>
_client.dictionary.countLabelled(
dictionary.language,
dictionary.source,
dictionary.glossLanguage,
label,
);
@ -56,7 +60,7 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
required int limit,
}) async => [
for (final hit in await _client.dictionary.labelled(
dictionary.language,
dictionary.source,
dictionary.glossLanguage,
label,
offset: offset,

View file

@ -13,11 +13,22 @@ typedef DictionaryCredit = ({
String url,
});
/// A dictionary the instance holds: words of [language] with meanings in
/// [glossLanguage] (BCP 47 tags), from [source] at [sourceVersion].
/// What a dictionary holds.
enum DictionaryKind() {
/// Words (JMdict).
words,
/// The characters words are written in (KANJIDIC2, with the kana).
characters,
}
/// A dictionary the instance holds: words or characters ([kind]) of
/// [language] with meanings in [glossLanguage] (BCP 47 tags), from [source]
/// at [sourceVersion].
final class const Dictionary({
required final String source,
required final String sourceVersion,
required final DictionaryKind kind,
required final String language,
required final String glossLanguage,
required final String license,
@ -28,6 +39,7 @@ final class const Dictionary({
List<Object?> get props => [
source,
sourceVersion,
kind,
language,
glossLanguage,
license,

View file

@ -102,10 +102,12 @@ void main() {
});
test("finds a studyset's dictionary by its front's language, with "
"meanings in its back's, else in English", () async {
"meanings in its back's, else in English; one of words unless a "
'kind is asked for', () async {
const german = Dictionary(
source: 'jmdict',
sourceVersion: '1',
kind: DictionaryKind.words,
language: 'ja',
glossLanguage: 'de',
license: 'CC-BY-SA-4.0',
@ -113,7 +115,7 @@ void main() {
entryCount: 1,
);
final repository = DictionaryRepository(
FakeDictionaryGateway(available: [jmdict, german]),
FakeDictionaryGateway(available: [kanjidic, jmdict, german]),
);
expect(await repository.dictionaryFor((front: 'ja', back: 'de')), german);
@ -121,6 +123,13 @@ void main() {
expect(await repository.dictionaryFor((front: 'ja', back: null)), jmdict);
expect(await repository.dictionaryFor((front: 'ko', back: 'en')), isNull);
expect(await repository.dictionaryFor((front: null, back: 'en')), isNull);
expect(
await repository.dictionaryFor((
front: 'ja',
back: 'en',
), kind: DictionaryKind.characters),
kanjidic,
);
});
test('brings every word of a level, a page at a time', () async {

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.da
const jmdict = Dictionary(
source: 'jmdict',
sourceVersion: '3.6.2 (2026-10-05)',
kind: DictionaryKind.words,
language: 'ja',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
@ -20,6 +21,26 @@ const jmdict = Dictionary(
entryCount: 3,
);
/// KANJIDIC2 in English, with the kana, as an instance that loaded it lists
/// it.
const kanjidic = Dictionary(
source: 'kanjidic',
sourceVersion: '2026-282',
kind: DictionaryKind.characters,
language: 'ja',
glossLanguage: 'en',
license: 'CC-BY-SA-4.0',
credits: [
(
name: 'KANJIDIC2',
by: 'Electronic Dictionary Research and Development Group (EDRDG)',
license: 'CC-BY-SA-4.0',
url: 'https://www.edrdg.org/edrdg/licence.html',
),
],
entryCount: 2,
);
/// A JMdict word: [headword] read [reading], meaning [glosses], [labels]
/// and the parts of speech [pos] on its one sense.
DictionaryWord word(