feat(server): dictionaries, loaded from files and searched
The server now holds dictionaries for everyone: `dictionary_entry` and `dictionary_source`, reference data owned by no one, never synced. It loads the dictionary files in its `dictionaries` folder at every start, those whose SHA-256 changed: in the background, each source replaced in one transaction, so searches meanwhile find the old entries; a file it can't read is logged and left out. All of JMdict loads in under half a minute. On an instance the folder is deploy/dictionaries, mounted read-only (compose.yaml); the server's README says how. DictionaryEndpoint (signed in): the dictionaries with their credits; a search (exact key, exact loose key, key starting with the query, loose key starting with it, each in the source's order; katakana as hiragana, macrons written out), some 150-450 ms over all of JMdict; and the entries with a label, counted and a page at a time (all JLPT N5 words). The curator now keeps spellings that aren't quite right apart, as `looseKeys` (romaji with long vowels typed once): "sushi" finds 寿司 before 数詞 (すうし). 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:
parent
a875a91f40
commit
3013bc04b3
62 changed files with 16048 additions and 73 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -24,6 +24,9 @@ pubspec_overrides.yaml
|
||||||
.scloud/
|
.scloud/
|
||||||
# Deployment secrets (see deploy/.env.example)
|
# Deployment secrets (see deploy/.env.example)
|
||||||
deploy/.env
|
deploy/.env
|
||||||
|
# Dictionary files an instance loads: made by halkyon_learn_curator, not kept in git.
|
||||||
|
deploy/dictionaries/*.jsonl
|
||||||
|
deploy/dictionaries/*.jsonl.gz
|
||||||
|
|
||||||
# Snaps built by `snapcraft pack` in the repository root.
|
# Snaps built by `snapcraft pack` in the repository root.
|
||||||
/*.snap
|
/*.snap
|
||||||
|
|
|
||||||
|
|
@ -144,6 +144,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
||||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||||
|
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start, those whose SHA-256 changed, each source replaced in one transaction (`dictionary_loader.dart`). A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only, so loading a newer version never touches cards. Every file carries its credits, which the app shows.
|
||||||
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
|
- **The Pomodoro timer** (`feature/pomodoro/`): the profiles follow the person (`PersonSettings.pomodoro`, settings schema 4), the running timer is the device's own (`PomodoroRun` in `<person folder>/pomodoro.json`, `PomodoroRunStore`). `PomodoroRun` is a pure state machine on the time: a phase that ends leads to the next, running if its profile starts it by itself, else paused at its full length; a timer kept while the app was closed catches up (`caughtUpTo`). `PomodoroBloc` sits above the router (`PersonApp`), and `PomodoroPresenter` hands every change to `PomodoroPlatform` as a `PomodoroNotice` in the app's language, and its buttons back. **Android:** the app's own Kotlin (`PomodoroService.kt`; MethodChannel `app.halkyon.learn/pomodoro`, EventChannel `…/buttons`): a `specialUse` foreground service with a countdown notification (chronometer), Pause/Resume, Skip, Stop, on Android 16 a Live Update (`NotificationCompat.ProgressStyle`, the cycle's phases as segments, `setRequestPromotedOngoing`); an exact alarm (`USE_EXACT_ALARM`) alerts at the phase's end with the app closed; Do Not Disturb through the interruption filter, the one before kept in shared preferences and restored. With the app swiped away its buttons open it (a service may not start an activity from a notification's button since Android 12, so the notification is posted again with activity intents), and Stop works alone, leaving a mark the app takes at its next start (`takeStoppedOutside`) to forget its timer; a profile's next phase only starts by itself while the app runs. Dart's timers sleep with the device, so `PomodoroBloc` also looks every 15 s while a phase runs, and the alarm's receiver wakes it (`phaseEnds`); a kept timer is caught up before it first shows, or its ended phase would alert again. No notification plugin: flutter_local_notifications had Live Updates only in a prerelease needing compileSdk 37 (checked 2026-10-07). **Linux:** `LinuxPomodoroPlatform`, over package:dbus: where the top bar shows icons (GNOME with the AppIndicator extension, built into Ubuntu; KDE) the timer counts down there with its buttons in the icon's menu (`StatusNotifierItem`, written in Dart and laid out as libayatana-appindicator lays itself out, within the snap's rules, `desktop-legacy` of snapd v2.77: the item at `/org/ayatana/NotificationItem/<id>`, the only path the label's signal `XAyatanaNewLabel` may come from, registered with the watcher by that path, since a snap may bind no `org.freedesktop.StatusNotifierItem-*` name; the menu at `/MenuBar`; registered again when the watcher restarts); elsewhere the timer shows in the app alone, and on GNOME the Pomodoro card suggests the extension until dismissed, asking GNOME Shell to install or switch it on (`InstallRemoteExtension`, `EnableExtension`) or opening its page (decided 2026-10-07: no notification stands in; xdg-desktop-portal's Background status is for Flatpaks only). An alert with sound comes through `org.freedesktop.Notifications` when a phase ends while the app runs, and GNOME's `show-banners` goes off while focusing, through `gsettings`; the value before and the dismissed suggestion are kept in `<app support>/`. Calls to the bus go one at a time (`SerialQueue`).
|
||||||
- **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`.
|
- **Citations** (`core/citation/`): a `Source` is plain text parts as the person typed them (authors as full names, one per line; year; title; `container`, what it is a part of; publisher; ISBN; link; `raw`, a citation a file brought, shown while nothing else is cited), and `formatCitation` cites it in the device's `CitationStyle` (APA 7, MLA 9, Chicago 18 notes and bibliography, decided 2026-10-07): it splits and abbreviates the names itself (inverted names, particles, suffixes; names written with `&` or "et al." stay as written), sets a work of its own in italics and a part of something apart from the whole by `container`, and writes a missing year as "n.d." in APA and Chicago. A step's sources (step schema 2) and a studyset's bibliography (`bibliography`, studyset schema 4, last writer wins like the rest of its payload) are lists of them, shown and changed through `SourceList` and `showSourcesSheet`, in reference-list order (alphabetical, whatever order they were added in); the path import reads them too, text as `raw` and the old format's `citation` as `container`.
|
||||||
- **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. A study session's Undo takes back its steps last first (ratings, burials and deletions alike, across its rounds), so it holds the cards it deletes (`hold`) until it ends, when they go after the usual time to undo; a card deleted in a session that never ended (the app killed) stays. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied).
|
- **What a card carries beside its type's fields** (card schema 5): up to six `hints` and an `explanation` (rich text, any type), a `report` that something on it is wrong (category, note, when; studied as before, its badge in the list), and a word's `senses` (glosses, label, JMdict's grammar codes, whose English short forms and meanings are data in `feature/card/model/jmdict_tags.dart`; with senses a word's meanings are their glosses, and `uk` on its first sense shows its reading from the start). Each merges as a field of its own. Deleting a card goes through `CardDeletions` (a `DelayedDeletions`, as notes and studysets), so the list and the session offer Undo. A study session's Undo takes back its steps last first (ratings, burials and deletions alike, across its rounds), so it holds the cards it deletes (`hold`) until it ends, when they go after the usual time to undo; a card deleted in a session that never ended (the app killed) stays. Vocabulary can be quizzed as multiple choice (`quizVocabulary`, `quizAspect` in the study options, settings schema 5; `vocabularyQuiz` draws the wrong answers from the other words studied).
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,10 @@ services:
|
||||||
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
|
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
|
||||||
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
|
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
|
||||||
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
|
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
|
||||||
|
# The dictionary files it loads at start, those that changed since (see the
|
||||||
|
# server's README); read only, and empty unless the operator adds some.
|
||||||
|
volumes:
|
||||||
|
- ./dictionaries:/app/dictionaries:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
|
||||||
5
deploy/dictionaries/README.md
Normal file
5
deploy/dictionaries/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Dictionary files
|
||||||
|
|
||||||
|
Dictionary files (`*.jsonl`, `*.jsonl.gz`) for the server to load at start; see
|
||||||
|
the server's README. They are downloads turned into files by `halkyon_learn_curator`,
|
||||||
|
not kept in git.
|
||||||
|
|
@ -16,6 +16,10 @@ import 'package:halkyon_learn_client/src/protocol/asset/file_usage.dart'
|
||||||
as _iimptq0q;
|
as _iimptq0q;
|
||||||
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
||||||
as _ieqh4yr9;
|
as _ieqh4yr9;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
||||||
|
as _iqbqiphu;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_source.dart'
|
||||||
|
as _i8vj64sp;
|
||||||
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
|
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
|
||||||
as _i26u8vl4;
|
as _i26u8vl4;
|
||||||
import 'package:http/http.dart' as _i85jenna;
|
import 'package:http/http.dart' as _i85jenna;
|
||||||
|
|
@ -220,6 +224,82 @@ class EndpointGitHubUpgrade extends _isc.EndpointRef {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The dictionaries the instance holds, to look words up in and to take
|
||||||
|
/// words from by a label (all JLPT N5 words). Reference data: any signed-in
|
||||||
|
/// person may read all of it.
|
||||||
|
/// {@category Endpoint}
|
||||||
|
class EndpointDictionary extends _isc.EndpointRef {
|
||||||
|
EndpointDictionary(_isc.EndpointCaller caller) : super(caller);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get name => 'dictionary';
|
||||||
|
|
||||||
|
/// The dictionaries there are, with whose work they are.
|
||||||
|
_ida.Future<List<_i8vj64sp.DictionarySource>> dictionaries() =>
|
||||||
|
caller.callServerEndpoint<List<_i8vj64sp.DictionarySource>>(
|
||||||
|
'dictionary',
|
||||||
|
'dictionaries',
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The entries of the dictionary of [language] with meanings in
|
||||||
|
/// [glossLanguage] that [query] finds, best first: those with a key that
|
||||||
|
/// is the query, then a loose key that is (`sushi` for すうし), then a key
|
||||||
|
/// starting with it, then a loose key; each in the dictionary's order. At
|
||||||
|
/// most [limit] (30 unless given).
|
||||||
|
_ida.Future<List<_iqbqiphu.DictionaryHit>> search(
|
||||||
|
String language,
|
||||||
|
String glossLanguage,
|
||||||
|
String query, {
|
||||||
|
int? limit,
|
||||||
|
}) => caller.callServerEndpoint<List<_iqbqiphu.DictionaryHit>>(
|
||||||
|
'dictionary',
|
||||||
|
'search',
|
||||||
|
{
|
||||||
|
'language': language,
|
||||||
|
'glossLanguage': glossLanguage,
|
||||||
|
'query': query,
|
||||||
|
'limit': limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// How many entries of the dictionary of [language] with meanings in
|
||||||
|
/// [glossLanguage] carry [label].
|
||||||
|
_ida.Future<int> countLabelled(
|
||||||
|
String language,
|
||||||
|
String glossLanguage,
|
||||||
|
String label,
|
||||||
|
) => caller.callServerEndpoint<int>(
|
||||||
|
'dictionary',
|
||||||
|
'countLabelled',
|
||||||
|
{
|
||||||
|
'language': language,
|
||||||
|
'glossLanguage': glossLanguage,
|
||||||
|
'label': label,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The entries that carry [label], in the dictionary's order: [limit] of
|
||||||
|
/// them (100 unless given, at most [maxPage]) after the first [offset].
|
||||||
|
_ida.Future<List<_iqbqiphu.DictionaryHit>> labelled(
|
||||||
|
String language,
|
||||||
|
String glossLanguage,
|
||||||
|
String label, {
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
}) => caller.callServerEndpoint<List<_iqbqiphu.DictionaryHit>>(
|
||||||
|
'dictionary',
|
||||||
|
'labelled',
|
||||||
|
{
|
||||||
|
'language': language,
|
||||||
|
'glossLanguage': glossLanguage,
|
||||||
|
'label': label,
|
||||||
|
'offset': offset,
|
||||||
|
'limit': limit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// What this instance offers. Public: the app asks before anyone is signed in.
|
/// What this instance offers. Public: the app asks before anyone is signed in.
|
||||||
/// {@category Endpoint}
|
/// {@category Endpoint}
|
||||||
class EndpointInstance extends _isc.EndpointRef {
|
class EndpointInstance extends _isc.EndpointRef {
|
||||||
|
|
@ -285,6 +365,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
devices = EndpointDevices(this);
|
devices = EndpointDevices(this);
|
||||||
gitHubSignIn = EndpointGitHubSignIn(this);
|
gitHubSignIn = EndpointGitHubSignIn(this);
|
||||||
gitHubUpgrade = EndpointGitHubUpgrade(this);
|
gitHubUpgrade = EndpointGitHubUpgrade(this);
|
||||||
|
dictionary = EndpointDictionary(this);
|
||||||
instance = EndpointInstance(this);
|
instance = EndpointInstance(this);
|
||||||
modules = Modules(this);
|
modules = Modules(this);
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +382,8 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
|
|
||||||
late final EndpointGitHubUpgrade gitHubUpgrade;
|
late final EndpointGitHubUpgrade gitHubUpgrade;
|
||||||
|
|
||||||
|
late final EndpointDictionary dictionary;
|
||||||
|
|
||||||
late final EndpointInstance instance;
|
late final EndpointInstance instance;
|
||||||
|
|
||||||
late final Modules modules;
|
late final Modules modules;
|
||||||
|
|
@ -313,6 +396,7 @@ class Client extends _isc.ServerpodClientShared {
|
||||||
'devices': devices,
|
'devices': devices,
|
||||||
'gitHubSignIn': gitHubSignIn,
|
'gitHubSignIn': gitHubSignIn,
|
||||||
'gitHubUpgrade': gitHubUpgrade,
|
'gitHubUpgrade': gitHubUpgrade,
|
||||||
|
'dictionary': dictionary,
|
||||||
'instance': instance,
|
'instance': instance,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// Who made what a dictionary is drawn from, under which licence.
|
||||||
|
abstract class DictionaryCredit
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
DictionaryCredit._({
|
||||||
|
required this.name,
|
||||||
|
required this.by,
|
||||||
|
required this.license,
|
||||||
|
required this.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DictionaryCredit({
|
||||||
|
required String name,
|
||||||
|
required String by,
|
||||||
|
required String license,
|
||||||
|
required String url,
|
||||||
|
}) = _DictionaryCreditImpl;
|
||||||
|
|
||||||
|
factory DictionaryCredit.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return DictionaryCredit(
|
||||||
|
name: jsonSerialization['name'] as String,
|
||||||
|
by: jsonSerialization['by'] as String,
|
||||||
|
license: jsonSerialization['license'] as String,
|
||||||
|
url: jsonSerialization['url'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What it is (`JMdict`).
|
||||||
|
String name;
|
||||||
|
|
||||||
|
/// Who made it.
|
||||||
|
String by;
|
||||||
|
|
||||||
|
/// Its licence, an SPDX identifier.
|
||||||
|
String license;
|
||||||
|
|
||||||
|
/// Where it is described.
|
||||||
|
String url;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionaryCredit]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
DictionaryCredit copyWith({
|
||||||
|
String? name,
|
||||||
|
String? by,
|
||||||
|
String? license,
|
||||||
|
String? url,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionaryCredit',
|
||||||
|
'name': name,
|
||||||
|
'by': by,
|
||||||
|
'license': license,
|
||||||
|
'url': url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionaryCredit',
|
||||||
|
'name': name,
|
||||||
|
'by': by,
|
||||||
|
'license': license,
|
||||||
|
'url': url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DictionaryCreditImpl extends DictionaryCredit {
|
||||||
|
_DictionaryCreditImpl({
|
||||||
|
required String name,
|
||||||
|
required String by,
|
||||||
|
required String license,
|
||||||
|
required String url,
|
||||||
|
}) : super._(
|
||||||
|
name: name,
|
||||||
|
by: by,
|
||||||
|
license: license,
|
||||||
|
url: url,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionaryCredit]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
DictionaryCredit copyWith({
|
||||||
|
String? name,
|
||||||
|
String? by,
|
||||||
|
String? license,
|
||||||
|
String? url,
|
||||||
|
}) {
|
||||||
|
return DictionaryCredit(
|
||||||
|
name: name ?? this.name,
|
||||||
|
by: by ?? this.by,
|
||||||
|
license: license ?? this.license,
|
||||||
|
url: url ?? this.url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// An entry of a dictionary the instance holds (JMdict, …): reference data for
|
||||||
|
/// everyone, owned by no one and never synced. The server loads it from a
|
||||||
|
/// dictionary file (dictionary_loader.dart; the format is the curator's README).
|
||||||
|
/// A card made from it copies what it needs and keeps the source and id only as
|
||||||
|
/// a soft reference, so loading a newer version never touches cards.
|
||||||
|
abstract class DictionaryEntry
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
DictionaryEntry._({
|
||||||
|
this.id,
|
||||||
|
required this.source,
|
||||||
|
required this.sourceId,
|
||||||
|
required this.language,
|
||||||
|
required this.glossLanguage,
|
||||||
|
required this.headword,
|
||||||
|
this.reading,
|
||||||
|
required this.rank,
|
||||||
|
required this.labels,
|
||||||
|
required this.searchKeys,
|
||||||
|
required this.looseKeys,
|
||||||
|
required this.entry,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DictionaryEntry({
|
||||||
|
int? id,
|
||||||
|
required String source,
|
||||||
|
required String sourceId,
|
||||||
|
required String language,
|
||||||
|
required String glossLanguage,
|
||||||
|
required String headword,
|
||||||
|
String? reading,
|
||||||
|
required int rank,
|
||||||
|
required String labels,
|
||||||
|
required String searchKeys,
|
||||||
|
required String looseKeys,
|
||||||
|
required String entry,
|
||||||
|
}) = _DictionaryEntryImpl;
|
||||||
|
|
||||||
|
factory DictionaryEntry.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return DictionaryEntry(
|
||||||
|
id: jsonSerialization['id'] as int?,
|
||||||
|
source: jsonSerialization['source'] as String,
|
||||||
|
sourceId: jsonSerialization['sourceId'] as String,
|
||||||
|
language: jsonSerialization['language'] as String,
|
||||||
|
glossLanguage: jsonSerialization['glossLanguage'] as String,
|
||||||
|
headword: jsonSerialization['headword'] as String,
|
||||||
|
reading: jsonSerialization['reading'] as String?,
|
||||||
|
rank: jsonSerialization['rank'] as int,
|
||||||
|
labels: jsonSerialization['labels'] as String,
|
||||||
|
searchKeys: jsonSerialization['searchKeys'] as String,
|
||||||
|
looseKeys: jsonSerialization['looseKeys'] as String,
|
||||||
|
entry: jsonSerialization['entry'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The database id, set if the object has been inserted into the
|
||||||
|
/// database or if it has been fetched from the database. Otherwise,
|
||||||
|
/// the id will be null.
|
||||||
|
int? id;
|
||||||
|
|
||||||
|
/// The source its file names (`jmdict`).
|
||||||
|
String source;
|
||||||
|
|
||||||
|
/// Its id in the source.
|
||||||
|
String sourceId;
|
||||||
|
|
||||||
|
/// The words' language, a BCP 47 tag (`ja`).
|
||||||
|
String language;
|
||||||
|
|
||||||
|
/// The meanings' language (`en`).
|
||||||
|
String glossLanguage;
|
||||||
|
|
||||||
|
/// How the word is shown.
|
||||||
|
String headword;
|
||||||
|
|
||||||
|
/// How it is read; null when the headword is its own reading.
|
||||||
|
String? reading;
|
||||||
|
|
||||||
|
/// Its order among the source's entries, lower first.
|
||||||
|
int rank;
|
||||||
|
|
||||||
|
/// Its labels between bars (`|common|jlpt:n5|`), for LIKE to find one.
|
||||||
|
String labels;
|
||||||
|
|
||||||
|
/// What a search matches, lower case, between bars
|
||||||
|
/// (`|食べる|たべる|taberu|eat|`).
|
||||||
|
String searchKeys;
|
||||||
|
|
||||||
|
/// What a search matches after every searchKeys match: spellings people
|
||||||
|
/// type that aren't quite right (`sushi` for すうし), between bars too.
|
||||||
|
String looseKeys;
|
||||||
|
|
||||||
|
/// The entry as cards take it: its JSON, as the file holds it.
|
||||||
|
String entry;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionaryEntry]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
DictionaryEntry copyWith({
|
||||||
|
int? id,
|
||||||
|
String? source,
|
||||||
|
String? sourceId,
|
||||||
|
String? language,
|
||||||
|
String? glossLanguage,
|
||||||
|
String? headword,
|
||||||
|
String? reading,
|
||||||
|
int? rank,
|
||||||
|
String? labels,
|
||||||
|
String? searchKeys,
|
||||||
|
String? looseKeys,
|
||||||
|
String? entry,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionaryEntry',
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'source': source,
|
||||||
|
'sourceId': sourceId,
|
||||||
|
'language': language,
|
||||||
|
'glossLanguage': glossLanguage,
|
||||||
|
'headword': headword,
|
||||||
|
if (reading != null) 'reading': reading,
|
||||||
|
'rank': rank,
|
||||||
|
'labels': labels,
|
||||||
|
'searchKeys': searchKeys,
|
||||||
|
'looseKeys': looseKeys,
|
||||||
|
'entry': entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionaryEntry',
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'source': source,
|
||||||
|
'sourceId': sourceId,
|
||||||
|
'language': language,
|
||||||
|
'glossLanguage': glossLanguage,
|
||||||
|
'headword': headword,
|
||||||
|
if (reading != null) 'reading': reading,
|
||||||
|
'rank': rank,
|
||||||
|
'labels': labels,
|
||||||
|
'searchKeys': searchKeys,
|
||||||
|
'looseKeys': looseKeys,
|
||||||
|
'entry': entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Undefined {}
|
||||||
|
|
||||||
|
class _DictionaryEntryImpl extends DictionaryEntry {
|
||||||
|
_DictionaryEntryImpl({
|
||||||
|
int? id,
|
||||||
|
required String source,
|
||||||
|
required String sourceId,
|
||||||
|
required String language,
|
||||||
|
required String glossLanguage,
|
||||||
|
required String headword,
|
||||||
|
String? reading,
|
||||||
|
required int rank,
|
||||||
|
required String labels,
|
||||||
|
required String searchKeys,
|
||||||
|
required String looseKeys,
|
||||||
|
required String entry,
|
||||||
|
}) : super._(
|
||||||
|
id: id,
|
||||||
|
source: source,
|
||||||
|
sourceId: sourceId,
|
||||||
|
language: language,
|
||||||
|
glossLanguage: glossLanguage,
|
||||||
|
headword: headword,
|
||||||
|
reading: reading,
|
||||||
|
rank: rank,
|
||||||
|
labels: labels,
|
||||||
|
searchKeys: searchKeys,
|
||||||
|
looseKeys: looseKeys,
|
||||||
|
entry: entry,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionaryEntry]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
DictionaryEntry copyWith({
|
||||||
|
Object? id = _Undefined,
|
||||||
|
String? source,
|
||||||
|
String? sourceId,
|
||||||
|
String? language,
|
||||||
|
String? glossLanguage,
|
||||||
|
String? headword,
|
||||||
|
Object? reading = _Undefined,
|
||||||
|
int? rank,
|
||||||
|
String? labels,
|
||||||
|
String? searchKeys,
|
||||||
|
String? looseKeys,
|
||||||
|
String? entry,
|
||||||
|
}) {
|
||||||
|
return DictionaryEntry(
|
||||||
|
id: id is int? ? id : this.id,
|
||||||
|
source: source ?? this.source,
|
||||||
|
sourceId: sourceId ?? this.sourceId,
|
||||||
|
language: language ?? this.language,
|
||||||
|
glossLanguage: glossLanguage ?? this.glossLanguage,
|
||||||
|
headword: headword ?? this.headword,
|
||||||
|
reading: reading is String? ? reading : this.reading,
|
||||||
|
rank: rank ?? this.rank,
|
||||||
|
labels: labels ?? this.labels,
|
||||||
|
searchKeys: searchKeys ?? this.searchKeys,
|
||||||
|
looseKeys: looseKeys ?? this.looseKeys,
|
||||||
|
entry: entry ?? this.entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
|
||||||
|
/// A dictionary entry as the app receives it: what it shows and turns into a
|
||||||
|
/// card, without what only the search needs.
|
||||||
|
abstract class DictionaryHit
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
DictionaryHit._({
|
||||||
|
required this.source,
|
||||||
|
required this.sourceId,
|
||||||
|
required this.headword,
|
||||||
|
this.reading,
|
||||||
|
required this.labels,
|
||||||
|
required this.entry,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DictionaryHit({
|
||||||
|
required String source,
|
||||||
|
required String sourceId,
|
||||||
|
required String headword,
|
||||||
|
String? reading,
|
||||||
|
required List<String> labels,
|
||||||
|
required String entry,
|
||||||
|
}) = _DictionaryHitImpl;
|
||||||
|
|
||||||
|
factory DictionaryHit.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return DictionaryHit(
|
||||||
|
source: jsonSerialization['source'] as String,
|
||||||
|
sourceId: jsonSerialization['sourceId'] as String,
|
||||||
|
headword: jsonSerialization['headword'] as String,
|
||||||
|
reading: jsonSerialization['reading'] as String?,
|
||||||
|
labels: _itx5j3f5.Protocol().deserialize<List<String>>(
|
||||||
|
jsonSerialization['labels'],
|
||||||
|
),
|
||||||
|
entry: jsonSerialization['entry'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The source and the entry's id in it: a card keeps them, to find the
|
||||||
|
/// word again and not to add it twice.
|
||||||
|
String source;
|
||||||
|
|
||||||
|
String sourceId;
|
||||||
|
|
||||||
|
String headword;
|
||||||
|
|
||||||
|
/// Null when the headword is its own reading.
|
||||||
|
String? reading;
|
||||||
|
|
||||||
|
/// `common`, `jlpt:n5`, …
|
||||||
|
List<String> labels;
|
||||||
|
|
||||||
|
/// The entry as cards take it, as JSON (the curator's README).
|
||||||
|
String entry;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionaryHit]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
DictionaryHit copyWith({
|
||||||
|
String? source,
|
||||||
|
String? sourceId,
|
||||||
|
String? headword,
|
||||||
|
String? reading,
|
||||||
|
List<String>? labels,
|
||||||
|
String? entry,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionaryHit',
|
||||||
|
'source': source,
|
||||||
|
'sourceId': sourceId,
|
||||||
|
'headword': headword,
|
||||||
|
if (reading != null) 'reading': reading,
|
||||||
|
'labels': labels.toJson(),
|
||||||
|
'entry': entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionaryHit',
|
||||||
|
'source': source,
|
||||||
|
'sourceId': sourceId,
|
||||||
|
'headword': headword,
|
||||||
|
if (reading != null) 'reading': reading,
|
||||||
|
'labels': labels.toJson(),
|
||||||
|
'entry': entry,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Undefined {}
|
||||||
|
|
||||||
|
class _DictionaryHitImpl extends DictionaryHit {
|
||||||
|
_DictionaryHitImpl({
|
||||||
|
required String source,
|
||||||
|
required String sourceId,
|
||||||
|
required String headword,
|
||||||
|
String? reading,
|
||||||
|
required List<String> labels,
|
||||||
|
required String entry,
|
||||||
|
}) : super._(
|
||||||
|
source: source,
|
||||||
|
sourceId: sourceId,
|
||||||
|
headword: headword,
|
||||||
|
reading: reading,
|
||||||
|
labels: labels,
|
||||||
|
entry: entry,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionaryHit]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
DictionaryHit copyWith({
|
||||||
|
String? source,
|
||||||
|
String? sourceId,
|
||||||
|
String? headword,
|
||||||
|
Object? reading = _Undefined,
|
||||||
|
List<String>? labels,
|
||||||
|
String? entry,
|
||||||
|
}) {
|
||||||
|
return DictionaryHit(
|
||||||
|
source: source ?? this.source,
|
||||||
|
sourceId: sourceId ?? this.sourceId,
|
||||||
|
headword: headword ?? this.headword,
|
||||||
|
reading: reading is String? ? reading : this.reading,
|
||||||
|
labels: labels ?? this.labels.map((e0) => e0).toList(),
|
||||||
|
entry: entry ?? this.entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,223 @@
|
||||||
|
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
|
||||||
|
/* To generate run: "serverpod generate" */
|
||||||
|
|
||||||
|
// ignore_for_file: implementation_imports
|
||||||
|
// ignore_for_file: library_private_types_in_public_api
|
||||||
|
// ignore_for_file: non_constant_identifier_names
|
||||||
|
// ignore_for_file: public_member_api_docs
|
||||||
|
// ignore_for_file: type_literal_in_constant_pattern
|
||||||
|
// ignore_for_file: use_super_parameters
|
||||||
|
// ignore_for_file: invalid_use_of_internal_member
|
||||||
|
|
||||||
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
|
||||||
|
import 'package:serverpod_client/serverpod_client.dart' as _isc;
|
||||||
|
import '../dictionary/dictionary_credit.dart' as _it7o1p0g;
|
||||||
|
|
||||||
|
/// A dictionary the instance holds: one source in one meaning language, as
|
||||||
|
/// loaded from its file. The app lists the credits where it names its data
|
||||||
|
/// sources; the file's hash tells the loader whether it changed since.
|
||||||
|
abstract class DictionarySource
|
||||||
|
implements _isc.SerializableModel, _isc.ProtocolSerialization {
|
||||||
|
DictionarySource._({
|
||||||
|
this.id,
|
||||||
|
required this.source,
|
||||||
|
required this.sourceVersion,
|
||||||
|
required this.language,
|
||||||
|
required this.glossLanguage,
|
||||||
|
required this.license,
|
||||||
|
required this.credits,
|
||||||
|
required this.entryCount,
|
||||||
|
required this.fileName,
|
||||||
|
required this.fileHash,
|
||||||
|
required this.loadedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory DictionarySource({
|
||||||
|
int? id,
|
||||||
|
required String source,
|
||||||
|
required String sourceVersion,
|
||||||
|
required String language,
|
||||||
|
required String glossLanguage,
|
||||||
|
required String license,
|
||||||
|
required List<_it7o1p0g.DictionaryCredit> credits,
|
||||||
|
required int entryCount,
|
||||||
|
required String fileName,
|
||||||
|
required String fileHash,
|
||||||
|
required DateTime loadedAt,
|
||||||
|
}) = _DictionarySourceImpl;
|
||||||
|
|
||||||
|
factory DictionarySource.fromJson(Map<String, dynamic> jsonSerialization) {
|
||||||
|
return DictionarySource(
|
||||||
|
id: jsonSerialization['id'] as int?,
|
||||||
|
source: jsonSerialization['source'] as String,
|
||||||
|
sourceVersion: jsonSerialization['sourceVersion'] as String,
|
||||||
|
language: jsonSerialization['language'] as String,
|
||||||
|
glossLanguage: jsonSerialization['glossLanguage'] as String,
|
||||||
|
license: jsonSerialization['license'] as String,
|
||||||
|
credits: _itx5j3f5.Protocol()
|
||||||
|
.deserialize<List<_it7o1p0g.DictionaryCredit>>(
|
||||||
|
jsonSerialization['credits'],
|
||||||
|
),
|
||||||
|
entryCount: jsonSerialization['entryCount'] as int,
|
||||||
|
fileName: jsonSerialization['fileName'] as String,
|
||||||
|
fileHash: jsonSerialization['fileHash'] as String,
|
||||||
|
loadedAt: _isc.DateTimeJsonExtension.fromJson(
|
||||||
|
jsonSerialization['loadedAt'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The database id, set if the object has been inserted into the
|
||||||
|
/// database or if it has been fetched from the database. Otherwise,
|
||||||
|
/// the id will be null.
|
||||||
|
int? id;
|
||||||
|
|
||||||
|
/// The source (`jmdict`).
|
||||||
|
String source;
|
||||||
|
|
||||||
|
/// Its version (`3.6.2 (2026-10-05)`).
|
||||||
|
String sourceVersion;
|
||||||
|
|
||||||
|
/// The words' language (`ja`).
|
||||||
|
String language;
|
||||||
|
|
||||||
|
/// The meanings' language (`en`).
|
||||||
|
String glossLanguage;
|
||||||
|
|
||||||
|
/// The data's licence, an SPDX identifier.
|
||||||
|
String license;
|
||||||
|
|
||||||
|
/// Everything it is drawn from.
|
||||||
|
List<_it7o1p0g.DictionaryCredit> credits;
|
||||||
|
|
||||||
|
/// How many entries it has.
|
||||||
|
int entryCount;
|
||||||
|
|
||||||
|
/// The file it was loaded from, and the file's SHA-256.
|
||||||
|
String fileName;
|
||||||
|
|
||||||
|
String fileHash;
|
||||||
|
|
||||||
|
/// When it was loaded, in UTC.
|
||||||
|
DateTime loadedAt;
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionarySource]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
DictionarySource copyWith({
|
||||||
|
int? id,
|
||||||
|
String? source,
|
||||||
|
String? sourceVersion,
|
||||||
|
String? language,
|
||||||
|
String? glossLanguage,
|
||||||
|
String? license,
|
||||||
|
List<_it7o1p0g.DictionaryCredit>? credits,
|
||||||
|
int? entryCount,
|
||||||
|
String? fileName,
|
||||||
|
String? fileHash,
|
||||||
|
DateTime? loadedAt,
|
||||||
|
});
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionarySource',
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'source': source,
|
||||||
|
'sourceVersion': sourceVersion,
|
||||||
|
'language': language,
|
||||||
|
'glossLanguage': glossLanguage,
|
||||||
|
'license': license,
|
||||||
|
'credits': credits.toJson(valueToJson: (v) => v.toJson()),
|
||||||
|
'entryCount': entryCount,
|
||||||
|
'fileName': fileName,
|
||||||
|
'fileHash': fileHash,
|
||||||
|
'loadedAt': loadedAt.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, dynamic> toJsonForProtocol() {
|
||||||
|
return {
|
||||||
|
'__className__': 'DictionarySource',
|
||||||
|
if (id != null) 'id': id,
|
||||||
|
'source': source,
|
||||||
|
'sourceVersion': sourceVersion,
|
||||||
|
'language': language,
|
||||||
|
'glossLanguage': glossLanguage,
|
||||||
|
'license': license,
|
||||||
|
'credits': credits.toJson(valueToJson: (v) => v.toJsonForProtocol()),
|
||||||
|
'entryCount': entryCount,
|
||||||
|
'fileName': fileName,
|
||||||
|
'fileHash': fileHash,
|
||||||
|
'loadedAt': loadedAt.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return _isc.SerializationManager.encode(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Undefined {}
|
||||||
|
|
||||||
|
class _DictionarySourceImpl extends DictionarySource {
|
||||||
|
_DictionarySourceImpl({
|
||||||
|
int? id,
|
||||||
|
required String source,
|
||||||
|
required String sourceVersion,
|
||||||
|
required String language,
|
||||||
|
required String glossLanguage,
|
||||||
|
required String license,
|
||||||
|
required List<_it7o1p0g.DictionaryCredit> credits,
|
||||||
|
required int entryCount,
|
||||||
|
required String fileName,
|
||||||
|
required String fileHash,
|
||||||
|
required DateTime loadedAt,
|
||||||
|
}) : super._(
|
||||||
|
id: id,
|
||||||
|
source: source,
|
||||||
|
sourceVersion: sourceVersion,
|
||||||
|
language: language,
|
||||||
|
glossLanguage: glossLanguage,
|
||||||
|
license: license,
|
||||||
|
credits: credits,
|
||||||
|
entryCount: entryCount,
|
||||||
|
fileName: fileName,
|
||||||
|
fileHash: fileHash,
|
||||||
|
loadedAt: loadedAt,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Returns a shallow copy of this [DictionarySource]
|
||||||
|
/// with some or all fields replaced by the given arguments.
|
||||||
|
@_isc.useResult
|
||||||
|
@override
|
||||||
|
DictionarySource copyWith({
|
||||||
|
Object? id = _Undefined,
|
||||||
|
String? source,
|
||||||
|
String? sourceVersion,
|
||||||
|
String? language,
|
||||||
|
String? glossLanguage,
|
||||||
|
String? license,
|
||||||
|
List<_it7o1p0g.DictionaryCredit>? credits,
|
||||||
|
int? entryCount,
|
||||||
|
String? fileName,
|
||||||
|
String? fileHash,
|
||||||
|
DateTime? loadedAt,
|
||||||
|
}) {
|
||||||
|
return DictionarySource(
|
||||||
|
id: id is int? ? id : this.id,
|
||||||
|
source: source ?? this.source,
|
||||||
|
sourceVersion: sourceVersion ?? this.sourceVersion,
|
||||||
|
language: language ?? this.language,
|
||||||
|
glossLanguage: glossLanguage ?? this.glossLanguage,
|
||||||
|
license: license ?? this.license,
|
||||||
|
credits: credits ?? this.credits.map((e0) => e0.copyWith()).toList(),
|
||||||
|
entryCount: entryCount ?? this.entryCount,
|
||||||
|
fileName: fileName ?? this.fileName,
|
||||||
|
fileHash: fileHash ?? this.fileHash,
|
||||||
|
loadedAt: loadedAt ?? this.loadedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,10 @@
|
||||||
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
// ignore_for_file: no_leading_underscores_for_library_prefixes
|
||||||
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
import 'package:halkyon_learn_client/src/protocol/auth/device.dart'
|
||||||
as _ieqh4yr9;
|
as _ieqh4yr9;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_hit.dart'
|
||||||
|
as _iqbqiphu;
|
||||||
|
import 'package:halkyon_learn_client/src/protocol/dictionary/dictionary_source.dart'
|
||||||
|
as _i8vj64sp;
|
||||||
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
import 'package:serverpod_auth_core_client/serverpod_auth_core_client.dart'
|
||||||
as _iacc;
|
as _iacc;
|
||||||
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
import 'package:serverpod_auth_idp_client/serverpod_auth_idp_client.dart'
|
||||||
|
|
@ -31,6 +35,10 @@ import 'auth/github_sign_in_refused_exception.dart' as _i0xfcu5n;
|
||||||
import 'card/card.dart' as _ipiw1cmx;
|
import 'card/card.dart' as _ipiw1cmx;
|
||||||
import 'card/card_revision.dart' as _ipbyuef1;
|
import 'card/card_revision.dart' as _ipbyuef1;
|
||||||
import 'common/not_found_exception.dart' as _i8oee73b;
|
import 'common/not_found_exception.dart' as _i8oee73b;
|
||||||
|
import 'dictionary/dictionary_credit.dart' as _ii4hopsr;
|
||||||
|
import 'dictionary/dictionary_entry.dart' as _i4ipxv77;
|
||||||
|
import 'dictionary/dictionary_hit.dart' as _ivbh47kc;
|
||||||
|
import 'dictionary/dictionary_source.dart' as _ie12eb65;
|
||||||
import 'instance/instance_info.dart' as _iskrg2g3;
|
import 'instance/instance_info.dart' as _iskrg2g3;
|
||||||
import 'learning_path/learning_path.dart' as _ivt5m868;
|
import 'learning_path/learning_path.dart' as _ivt5m868;
|
||||||
import 'learning_path/learning_path_answer.dart' as _iy9sv3bs;
|
import 'learning_path/learning_path_answer.dart' as _iy9sv3bs;
|
||||||
|
|
@ -57,6 +65,10 @@ export 'auth/github_sign_in_refused_exception.dart';
|
||||||
export 'card/card.dart';
|
export 'card/card.dart';
|
||||||
export 'card/card_revision.dart';
|
export 'card/card_revision.dart';
|
||||||
export 'common/not_found_exception.dart';
|
export 'common/not_found_exception.dart';
|
||||||
|
export 'dictionary/dictionary_credit.dart';
|
||||||
|
export 'dictionary/dictionary_entry.dart';
|
||||||
|
export 'dictionary/dictionary_hit.dart';
|
||||||
|
export 'dictionary/dictionary_source.dart';
|
||||||
export 'instance/instance_info.dart';
|
export 'instance/instance_info.dart';
|
||||||
export 'learning_path/learning_path.dart';
|
export 'learning_path/learning_path.dart';
|
||||||
export 'learning_path/learning_path_answer.dart';
|
export 'learning_path/learning_path_answer.dart';
|
||||||
|
|
@ -1993,6 +2005,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (t == _i8oee73b.NotFoundException) {
|
if (t == _i8oee73b.NotFoundException) {
|
||||||
return _i8oee73b.NotFoundException.fromJson(data) as T;
|
return _i8oee73b.NotFoundException.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
if (t == _ii4hopsr.DictionaryCredit) {
|
||||||
|
return _ii4hopsr.DictionaryCredit.fromJson(data) as T;
|
||||||
|
}
|
||||||
|
if (t == _i4ipxv77.DictionaryEntry) {
|
||||||
|
return _i4ipxv77.DictionaryEntry.fromJson(data) as T;
|
||||||
|
}
|
||||||
|
if (t == _ivbh47kc.DictionaryHit) {
|
||||||
|
return _ivbh47kc.DictionaryHit.fromJson(data) as T;
|
||||||
|
}
|
||||||
|
if (t == _ie12eb65.DictionarySource) {
|
||||||
|
return _ie12eb65.DictionarySource.fromJson(data) as T;
|
||||||
|
}
|
||||||
if (t == _iskrg2g3.InstanceInfo) {
|
if (t == _iskrg2g3.InstanceInfo) {
|
||||||
return _iskrg2g3.InstanceInfo.fromJson(data) as T;
|
return _iskrg2g3.InstanceInfo.fromJson(data) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -2082,6 +2106,22 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
return (data != null ? _i8oee73b.NotFoundException.fromJson(data) : null)
|
return (data != null ? _i8oee73b.NotFoundException.fromJson(data) : null)
|
||||||
as T;
|
as T;
|
||||||
}
|
}
|
||||||
|
if (t == _isc.getType<_ii4hopsr.DictionaryCredit?>()) {
|
||||||
|
return (data != null ? _ii4hopsr.DictionaryCredit.fromJson(data) : null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
if (t == _isc.getType<_i4ipxv77.DictionaryEntry?>()) {
|
||||||
|
return (data != null ? _i4ipxv77.DictionaryEntry.fromJson(data) : null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
if (t == _isc.getType<_ivbh47kc.DictionaryHit?>()) {
|
||||||
|
return (data != null ? _ivbh47kc.DictionaryHit.fromJson(data) : null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
if (t == _isc.getType<_ie12eb65.DictionarySource?>()) {
|
||||||
|
return (data != null ? _ie12eb65.DictionarySource.fromJson(data) : null)
|
||||||
|
as T;
|
||||||
|
}
|
||||||
if (t == _isc.getType<_iskrg2g3.InstanceInfo?>()) {
|
if (t == _isc.getType<_iskrg2g3.InstanceInfo?>()) {
|
||||||
return (data != null ? _iskrg2g3.InstanceInfo.fromJson(data) : null) as T;
|
return (data != null ? _iskrg2g3.InstanceInfo.fromJson(data) : null) as T;
|
||||||
}
|
}
|
||||||
|
|
@ -2141,12 +2181,33 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
return (data as List).map((e) => deserialize<_isc.UuidValue>(e)).toList()
|
return (data as List).map((e) => deserialize<_isc.UuidValue>(e)).toList()
|
||||||
as T;
|
as T;
|
||||||
}
|
}
|
||||||
|
if (t == List<String>) {
|
||||||
|
return (data as List).map((e) => deserialize<String>(e)).toList() as T;
|
||||||
|
}
|
||||||
|
if (t == List<_ii4hopsr.DictionaryCredit>) {
|
||||||
|
return (data as List)
|
||||||
|
.map((e) => deserialize<_ii4hopsr.DictionaryCredit>(e))
|
||||||
|
.toList()
|
||||||
|
as T;
|
||||||
|
}
|
||||||
if (t == List<_ieqh4yr9.Device>) {
|
if (t == List<_ieqh4yr9.Device>) {
|
||||||
return (data as List)
|
return (data as List)
|
||||||
.map((e) => deserialize<_ieqh4yr9.Device>(e))
|
.map((e) => deserialize<_ieqh4yr9.Device>(e))
|
||||||
.toList()
|
.toList()
|
||||||
as T;
|
as T;
|
||||||
}
|
}
|
||||||
|
if (t == List<_i8vj64sp.DictionarySource>) {
|
||||||
|
return (data as List)
|
||||||
|
.map((e) => deserialize<_i8vj64sp.DictionarySource>(e))
|
||||||
|
.toList()
|
||||||
|
as T;
|
||||||
|
}
|
||||||
|
if (t == List<_iqbqiphu.DictionaryHit>) {
|
||||||
|
return (data as List)
|
||||||
|
.map((e) => deserialize<_iqbqiphu.DictionaryHit>(e))
|
||||||
|
.toList()
|
||||||
|
as T;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return _iaic.Protocol().deserialize<T>(data, t);
|
return _iaic.Protocol().deserialize<T>(data, t);
|
||||||
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
} on _isc.DeserializationTypeNotFoundException catch (_) {}
|
||||||
|
|
@ -2171,6 +2232,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
_ipiw1cmx.Card => 'Card',
|
_ipiw1cmx.Card => 'Card',
|
||||||
_ipbyuef1.CardRevision => 'CardRevision',
|
_ipbyuef1.CardRevision => 'CardRevision',
|
||||||
_i8oee73b.NotFoundException => 'NotFoundException',
|
_i8oee73b.NotFoundException => 'NotFoundException',
|
||||||
|
_ii4hopsr.DictionaryCredit => 'DictionaryCredit',
|
||||||
|
_i4ipxv77.DictionaryEntry => 'DictionaryEntry',
|
||||||
|
_ivbh47kc.DictionaryHit => 'DictionaryHit',
|
||||||
|
_ie12eb65.DictionarySource => 'DictionarySource',
|
||||||
_iskrg2g3.InstanceInfo => 'InstanceInfo',
|
_iskrg2g3.InstanceInfo => 'InstanceInfo',
|
||||||
_ivt5m868.LearningPath => 'LearningPath',
|
_ivt5m868.LearningPath => 'LearningPath',
|
||||||
_iy9sv3bs.LearningPathAnswer => 'LearningPathAnswer',
|
_iy9sv3bs.LearningPathAnswer => 'LearningPathAnswer',
|
||||||
|
|
@ -2224,6 +2289,14 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
return 'CardRevision';
|
return 'CardRevision';
|
||||||
case _i8oee73b.NotFoundException():
|
case _i8oee73b.NotFoundException():
|
||||||
return 'NotFoundException';
|
return 'NotFoundException';
|
||||||
|
case _ii4hopsr.DictionaryCredit():
|
||||||
|
return 'DictionaryCredit';
|
||||||
|
case _i4ipxv77.DictionaryEntry():
|
||||||
|
return 'DictionaryEntry';
|
||||||
|
case _ivbh47kc.DictionaryHit():
|
||||||
|
return 'DictionaryHit';
|
||||||
|
case _ie12eb65.DictionarySource():
|
||||||
|
return 'DictionarySource';
|
||||||
case _iskrg2g3.InstanceInfo():
|
case _iskrg2g3.InstanceInfo():
|
||||||
return 'InstanceInfo';
|
return 'InstanceInfo';
|
||||||
case _ivt5m868.LearningPath():
|
case _ivt5m868.LearningPath():
|
||||||
|
|
@ -2314,6 +2387,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
||||||
if (dataClassName == 'NotFoundException') {
|
if (dataClassName == 'NotFoundException') {
|
||||||
return deserialize<_i8oee73b.NotFoundException>(data['data']);
|
return deserialize<_i8oee73b.NotFoundException>(data['data']);
|
||||||
}
|
}
|
||||||
|
if (dataClassName == 'DictionaryCredit') {
|
||||||
|
return deserialize<_ii4hopsr.DictionaryCredit>(data['data']);
|
||||||
|
}
|
||||||
|
if (dataClassName == 'DictionaryEntry') {
|
||||||
|
return deserialize<_i4ipxv77.DictionaryEntry>(data['data']);
|
||||||
|
}
|
||||||
|
if (dataClassName == 'DictionaryHit') {
|
||||||
|
return deserialize<_ivbh47kc.DictionaryHit>(data['data']);
|
||||||
|
}
|
||||||
|
if (dataClassName == 'DictionarySource') {
|
||||||
|
return deserialize<_ie12eb65.DictionarySource>(data['data']);
|
||||||
|
}
|
||||||
if (dataClassName == 'InstanceInfo') {
|
if (dataClassName == 'InstanceInfo') {
|
||||||
return deserialize<_iskrg2g3.InstanceInfo>(data['data']);
|
return deserialize<_iskrg2g3.InstanceInfo>(data['data']);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ The **first line** says what the file holds:
|
||||||
| `labels` | What it can be picked by: `common`, `jlpt:n5` … `jlpt:n1` (later e.g. `hsk:3`, `topik:1`). |
|
| `labels` | What it can be picked by: `common`, `jlpt:n5` … `jlpt:n1` (later e.g. `hsk:3`, `topik:1`). |
|
||||||
| `rank` | Its order among the source's entries, lower first; JMdict: N5 to N1 in their lists' order, then other common words, then the rest. |
|
| `rank` | Its order among the source's entries, lower first; JMdict: N5 to N1 in their lists' order, then other common words, then the rest. |
|
||||||
| `keys` | What a search matches, lower case: written forms, readings in their own script and romanised, meanings. Everything specific to a language happens here, so the server's search knows no language. |
|
| `keys` | What a search matches, lower case: written forms, readings in their own script and romanised, meanings. Everything specific to a language happens here, so the server's search knows no language. |
|
||||||
|
| `looseKeys` | What a search matches after every `keys` match: spellings people type that aren't quite right (romaji with long vowels typed once, `sushi` for すうし). Left out when empty. |
|
||||||
| `entry` | The entry itself, as cards take it: `forms` and `readings` (`text`, `common`, the source's `tags`; for a reading the `forms` it belongs to), `senses` (`glosses`; the source's codes in `pos`, `misc`, `field`, `dialect`; `info`; the `forms` and `readings` a sense is limited to). Lists are left out when empty. |
|
| `entry` | The entry itself, as cards take it: `forms` and `readings` (`text`, `common`, the source's `tags`; for a reading the `forms` it belongs to), `senses` (`glosses`; the source's codes in `pos`, `misc`, `field`, `dialect`; `info`; the `forms` and `readings` a sense is limited to). Lists are left out when empty. |
|
||||||
|
|
||||||
## Adding a source
|
## Adding a source
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,10 @@ final class const DictionaryHeader({
|
||||||
/// One entry of a dictionary file: its [id] in the source, the [headword]
|
/// One entry of a dictionary file: its [id] in the source, the [headword]
|
||||||
/// and its [reading] (null when the headword is its own reading), [labels]
|
/// and its [reading] (null when the headword is its own reading), [labels]
|
||||||
/// to choose entries by (`common`, `jlpt:n5`), a [rank] to order them by
|
/// to choose entries by (`common`, `jlpt:n5`), a [rank] to order them by
|
||||||
/// (lower first), the [keys] a search matches against (lower case), and
|
/// (lower first), the [keys] a search matches against (lower case), the
|
||||||
/// the [entry] itself as the app turns it into a card.
|
/// [looseKeys] it matches after them (spellings people type that aren't
|
||||||
|
/// quite right, as `sushi` for すうし), and the [entry] itself as the app
|
||||||
|
/// turns it into a card.
|
||||||
final class const DictionaryEntry({
|
final class const DictionaryEntry({
|
||||||
required final String id,
|
required final String id,
|
||||||
required final String headword,
|
required final String headword,
|
||||||
|
|
@ -65,6 +67,7 @@ final class const DictionaryEntry({
|
||||||
required final int rank,
|
required final int rank,
|
||||||
required final List<String> keys,
|
required final List<String> keys,
|
||||||
required final Map<String, Object?> entry,
|
required final Map<String, Object?> entry,
|
||||||
|
final List<String> looseKeys = const [],
|
||||||
}) {
|
}) {
|
||||||
Map<String, Object?> toJson() => {
|
Map<String, Object?> toJson() => {
|
||||||
'id': id,
|
'id': id,
|
||||||
|
|
@ -73,6 +76,7 @@ final class const DictionaryEntry({
|
||||||
'labels': labels,
|
'labels': labels,
|
||||||
'rank': rank,
|
'rank': rank,
|
||||||
'keys': keys,
|
'keys': keys,
|
||||||
|
if (looseKeys.isNotEmpty) 'looseKeys': looseKeys,
|
||||||
'entry': entry,
|
'entry': entry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -124,10 +124,14 @@ DictionaryEntry jmdictEntry(
|
||||||
form.text,
|
form.text,
|
||||||
toHiragana(form.text),
|
toHiragana(form.text),
|
||||||
toRomaji(form.text),
|
toRomaji(form.text),
|
||||||
?withShortVowels(toRomaji(form.text)),
|
|
||||||
],
|
],
|
||||||
for (final gloss in glosses) ...glossKeys(gloss),
|
for (final gloss in glosses) ...glossKeys(gloss),
|
||||||
};
|
};
|
||||||
|
// Long vowels typed once (`tokyo`), after the right spellings: `sushi`
|
||||||
|
// finds 寿司 (すし) before 数詞 (すうし).
|
||||||
|
final looseKeys = {
|
||||||
|
for (final form in kana) ?withShortVowels(toRomaji(form.text)),
|
||||||
|
}.difference(keys);
|
||||||
|
|
||||||
return DictionaryEntry(
|
return DictionaryEntry(
|
||||||
id: id,
|
id: id,
|
||||||
|
|
@ -140,6 +144,7 @@ DictionaryEntry jmdictEntry(
|
||||||
null => common ? 600000 : 900000,
|
null => common ? 600000 : 900000,
|
||||||
},
|
},
|
||||||
keys: keys.toList(),
|
keys: keys.toList(),
|
||||||
|
looseKeys: looseKeys.toList(),
|
||||||
entry: {
|
entry: {
|
||||||
'forms': [
|
'forms': [
|
||||||
for (final form in kanji)
|
for (final form in kanji)
|
||||||
|
|
|
||||||
|
|
@ -66,8 +66,15 @@ void main() {
|
||||||
expect(word['keys'], containsAll(['テレビ', 'てれび', 'terebi', 'tv']));
|
expect(word['keys'], containsAll(['テレビ', 'てれび', 'terebi', 'tv']));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('long vowels are found typed once', () {
|
test('long vowels typed once are loose keys, found after the right '
|
||||||
expect(entry('東京')['keys'], containsAll(['toukyou', 'tokyo']));
|
'spellings', () {
|
||||||
|
final tokyo = entry('東京');
|
||||||
|
expect(tokyo['keys'], containsAll(['toukyou', 'toukei']));
|
||||||
|
// Its other reading, とうけい, typed with one o.
|
||||||
|
expect(tokyo['looseKeys'], ['tokei']);
|
||||||
|
// "tokyo" is a key already, its meaning in English.
|
||||||
|
expect(tokyo['keys'], contains('tokyo'));
|
||||||
|
expect(entry('食べる').containsKey('looseKeys'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the reading is one that belongs to the headword', () {
|
test('the reading is one that belongs to the headword', () {
|
||||||
|
|
|
||||||
4
halkyon_learn_server/.gitignore
vendored
4
halkyon_learn_server/.gitignore
vendored
|
|
@ -20,3 +20,7 @@ config/firebase_service_account_key.json
|
||||||
|
|
||||||
# SQLite databases created by the ephemeral test database
|
# SQLite databases created by the ephemeral test database
|
||||||
**/sp_*.db*
|
**/sp_*.db*
|
||||||
|
|
||||||
|
# Dictionary files: made by halkyon_learn_curator, not kept in git.
|
||||||
|
/dictionaries/*.jsonl
|
||||||
|
/dictionaries/*.jsonl.gz
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,26 @@ Start your server by running:
|
||||||
|
|
||||||
When you are finished, you can shut down the running server with `Q`.
|
When you are finished, you can shut down the running server with `Q`.
|
||||||
|
|
||||||
|
## Dictionaries
|
||||||
|
|
||||||
|
The dictionaries people look words up in (JMdict, …) are reference data the
|
||||||
|
server holds for everyone. They come as dictionary files, which
|
||||||
|
`halkyon_learn_curator` makes from the sources' downloads (its README says how).
|
||||||
|
At every start, the server loads the files in its `dictionaries` folder that
|
||||||
|
changed since they were last loaded (by their SHA-256): in the background, a
|
||||||
|
source at a time, and all at once, so searches meanwhile still find the old
|
||||||
|
entries. A file it can't read is logged and left out.
|
||||||
|
|
||||||
|
- **Development:** put the file into `halkyon_learn_server/dictionaries/` and
|
||||||
|
restart `serverpod start`.
|
||||||
|
- **An instance:** put it into `deploy/dictionaries/` beside `compose.yaml`
|
||||||
|
(mounted read-only as `/app/dictionaries`) and restart the server container.
|
||||||
|
`DICTIONARIES_DIR` names another folder.
|
||||||
|
|
||||||
|
JMdict in English, with the JLPT levels, is about 17 MB gzipped and takes the
|
||||||
|
server half a minute to load (219,000 entries). Its licence (CC BY-SA 4.0) asks
|
||||||
|
for attribution: the app lists the credits each file carries.
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
Copyright (C) 2026 The Halkyon Learn contributors
|
Copyright (C) 2026 The Halkyon Learn contributors
|
||||||
|
|
|
||||||
5
halkyon_learn_server/dictionaries/README.md
Normal file
5
halkyon_learn_server/dictionaries/README.md
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Dictionary files
|
||||||
|
|
||||||
|
Dictionary files (`*.jsonl`, `*.jsonl.gz`) for the server to load at start; see
|
||||||
|
the server's README. They are downloads turned into files by `halkyon_learn_curator`,
|
||||||
|
not kept in git.
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
|
import 'package:halkyon_learn_server/src/auth/auth_config.dart';
|
||||||
|
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
|
||||||
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
|
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
|
||||||
import 'package:serverpod_auth_idp_server/core.dart';
|
import 'package:serverpod_auth_idp_server/core.dart';
|
||||||
|
|
||||||
|
|
@ -37,6 +41,17 @@ Future<void> run(List<String> args) async {
|
||||||
.cron('47 3 * * *')
|
.cron('47 3 * * *')
|
||||||
.filePruning
|
.filePruning
|
||||||
.pruneFiles();
|
.pruneFiles();
|
||||||
|
|
||||||
|
// The dictionaries the operator put into the server's folder, those that
|
||||||
|
// changed since; in the background, as loading takes a minute or two.
|
||||||
|
unawaited(
|
||||||
|
pod.withSession(
|
||||||
|
(session) => loadDictionaries(
|
||||||
|
session,
|
||||||
|
Directory(Platform.environment['DICTIONARIES_DIR'] ?? 'dictionaries'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The identifier of the daily pruning.
|
/// The identifier of the daily pruning.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
### Who made what a dictionary is drawn from, under which licence.
|
||||||
|
class: DictionaryCredit
|
||||||
|
fields:
|
||||||
|
### What it is (`JMdict`).
|
||||||
|
name: String
|
||||||
|
### Who made it.
|
||||||
|
by: String
|
||||||
|
### Its licence, an SPDX identifier.
|
||||||
|
license: String
|
||||||
|
### Where it is described.
|
||||||
|
url: String
|
||||||
139
halkyon_learn_server/lib/src/dictionary/dictionary_endpoint.dart
Normal file
139
halkyon_learn_server/lib/src/dictionary/dictionary_endpoint.dart
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
import 'package:halkyon_learn_server/src/dictionary/dictionary_search.dart';
|
||||||
|
import 'package:halkyon_learn_server/src/generated/protocol.dart';
|
||||||
|
import 'package:serverpod/serverpod.dart';
|
||||||
|
|
||||||
|
/// The dictionaries the instance holds, to look words up in and to take
|
||||||
|
/// words from by a label (all JLPT N5 words). Reference data: any signed-in
|
||||||
|
/// person may read all of it.
|
||||||
|
class DictionaryEndpoint extends Endpoint {
|
||||||
|
@override
|
||||||
|
bool get requireLogin => true;
|
||||||
|
|
||||||
|
/// The most a search returns.
|
||||||
|
static const maxSearchResults = 50;
|
||||||
|
|
||||||
|
/// The most one page of labelled entries holds.
|
||||||
|
static const maxPage = 500;
|
||||||
|
|
||||||
|
/// The dictionaries there are, with whose work they are.
|
||||||
|
Future<List<DictionarySource>> dictionaries(Session session) =>
|
||||||
|
DictionarySource.db.find(
|
||||||
|
session,
|
||||||
|
orderBy: (t) => t.language,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The entries of the dictionary of [language] with meanings in
|
||||||
|
/// [glossLanguage] that [query] finds, best first: those with a key that
|
||||||
|
/// is the query, then a loose key that is (`sushi` for すうし), then a key
|
||||||
|
/// starting with it, then a loose key; each in the dictionary's order. At
|
||||||
|
/// most [limit] (30 unless given).
|
||||||
|
Future<List<DictionaryHit>> search(
|
||||||
|
Session session,
|
||||||
|
String language,
|
||||||
|
String glossLanguage,
|
||||||
|
String query, {
|
||||||
|
int? limit,
|
||||||
|
}) async {
|
||||||
|
final variants = queryVariants(language, query);
|
||||||
|
if (variants.isEmpty) {
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
final count = (limit ?? 30).clamp(1, maxSearchResults);
|
||||||
|
Future<List<DictionaryEntry>> matching(String Function(String) pattern) =>
|
||||||
|
DictionaryEntry.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) {
|
||||||
|
Expression<Object?> keys = Constant.bool(false);
|
||||||
|
for (final variant in variants) {
|
||||||
|
final like = pattern(likeEscaped(variant));
|
||||||
|
keys = keys | t.searchKeys.like(like) | t.looseKeys.like(like);
|
||||||
|
}
|
||||||
|
return t.language.equals(language) &
|
||||||
|
t.glossLanguage.equals(glossLanguage) &
|
||||||
|
keys;
|
||||||
|
},
|
||||||
|
orderBy: (t) => t.rank,
|
||||||
|
limit: count,
|
||||||
|
);
|
||||||
|
// SELECT … WHERE "searchKeys" LIKE '%|eat|%' OR "looseKeys" LIKE … ORDER
|
||||||
|
// BY "rank" LIMIT …, then LIKE '%|eat%' for keys starting with it.
|
||||||
|
final candidates = {
|
||||||
|
for (final entry in [
|
||||||
|
...await matching((key) => '%|$key|%'),
|
||||||
|
...await matching((key) => '%|$key%'),
|
||||||
|
])
|
||||||
|
entry.id: entry,
|
||||||
|
}.values.toList();
|
||||||
|
int tier(DictionaryEntry entry) {
|
||||||
|
bool any(String keys, String Function(String) pattern) => variants.any(
|
||||||
|
(variant) => keys.contains(pattern(variant.replaceAll('|', ' '))),
|
||||||
|
);
|
||||||
|
return any(entry.searchKeys, (key) => '|$key|')
|
||||||
|
? 0
|
||||||
|
: any(entry.looseKeys, (key) => '|$key|')
|
||||||
|
? 1
|
||||||
|
: any(entry.searchKeys, (key) => '|$key')
|
||||||
|
? 2
|
||||||
|
: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
final tiers = {for (final entry in candidates) entry.id: tier(entry)};
|
||||||
|
candidates.sort(
|
||||||
|
(a, b) => switch (tiers[a.id]!.compareTo(tiers[b.id]!)) {
|
||||||
|
0 => a.rank.compareTo(b.rank),
|
||||||
|
final order => order,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return [for (final entry in candidates.take(count)) _hit(entry)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many entries of the dictionary of [language] with meanings in
|
||||||
|
/// [glossLanguage] carry [label].
|
||||||
|
Future<int> countLabelled(
|
||||||
|
Session session,
|
||||||
|
String language,
|
||||||
|
String glossLanguage,
|
||||||
|
String label,
|
||||||
|
) => DictionaryEntry.db.count(
|
||||||
|
session,
|
||||||
|
where: (t) =>
|
||||||
|
t.language.equals(language) &
|
||||||
|
t.glossLanguage.equals(glossLanguage) &
|
||||||
|
t.labels.like('%|${likeEscaped(label)}|%'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// The entries that carry [label], in the dictionary's order: [limit] of
|
||||||
|
/// them (100 unless given, at most [maxPage]) after the first [offset].
|
||||||
|
Future<List<DictionaryHit>> labelled(
|
||||||
|
Session session,
|
||||||
|
String language,
|
||||||
|
String glossLanguage,
|
||||||
|
String label, {
|
||||||
|
int? offset,
|
||||||
|
int? limit,
|
||||||
|
}) async => [
|
||||||
|
for (final entry in await DictionaryEntry.db.find(
|
||||||
|
session,
|
||||||
|
where: (t) =>
|
||||||
|
t.language.equals(language) &
|
||||||
|
t.glossLanguage.equals(glossLanguage) &
|
||||||
|
t.labels.like('%|${likeEscaped(label)}|%'),
|
||||||
|
orderBy: (t) => t.rank,
|
||||||
|
offset: (offset ?? 0).clamp(0, 1 << 30),
|
||||||
|
limit: (limit ?? 100).clamp(1, maxPage),
|
||||||
|
))
|
||||||
|
_hit(entry),
|
||||||
|
];
|
||||||
|
|
||||||
|
DictionaryHit _hit(DictionaryEntry entry) => DictionaryHit(
|
||||||
|
source: entry.source,
|
||||||
|
sourceId: entry.sourceId,
|
||||||
|
headword: entry.headword,
|
||||||
|
reading: entry.reading,
|
||||||
|
labels: [
|
||||||
|
for (final label in entry.labels.split('|'))
|
||||||
|
if (label.isNotEmpty) label,
|
||||||
|
],
|
||||||
|
entry: entry.entry,
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue