feat: a dictionary's corrections reach the cards made from it, by offer
All checks were successful
CI / check (push) Successful in 12m33s
CI / deploy (push) Successful in 1m26s

Every dictionary entry now has a version: 16 hex digits of the SHA-256 of
what cards take of it (headword, reading, entry), made by the loader and
carried on every hit. A card keeps the version it took (card schema 8).
DictionaryEndpoint.changed returns, of the entries named with the
versions cards keep (1,000 at most a call; the app asks 500 at a time),
those whose version is another now.

The card browser says how many cards are behind their dictionary and takes
the corrections at a tap (decided 2026-10-09: by offer, never by
themselves). Taking one writes the entry as it is now over the revision
in which the card last took it, beside whatever came after, so the
revisions merge per side as two devices' edits do (ADR 0003): a side only
the dictionary changed takes the correction, a side the person changed
too is a conflict settled in the composer, whose words now name that
case. Cards made before versions are never offered one.

The server rereads its dictionary folder hourly now, not daily: the
dictionaries service fills its volume after an instance's first start,
and an hour with nothing changed costs a hash of each file.

Migration 20261009145434785-dictionary-entry-version: adds
dictionary_entry.version, text NOT NULL DEFAULT '' (rows loaded before
have none and are never reported as corrected), and by hand empties
dictionary_source."fileHash", so every source loads once more and its
entries get their versions; the schema stays as definition.sql says. The
old server goes on working until the switch.

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 17:32:00 +02:00
commit 44cee0d6c6
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
49 changed files with 12710 additions and 28 deletions

View file

@ -144,7 +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), `write` (by hand, 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.
- **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; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; 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 and daily (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters.
- **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; a dictionary is one source in one meaning language (`jmdict` in `en`), of words or of characters (`kind`: JMdict, KANJIDIC2 with the kana), and the endpoints take it by those two, so a search for words never brings kanji; 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 and hourly (`DictionaryReloadFutureCall`), those whose SHA-256 changed, each source replaced in one transaction, one load at a time (`dictionary_loader.dart`). EDRDG's licence asks a server showing its dictionaries to update them at least monthly (decided 2026-10-09: instances keep current by themselves, weekly): the `dictionaries` service of `deploy/compose.yaml` runs the curator in a container of its own (`halkyon_learn_curator/Dockerfile`, user ID 10001; the AGPL tool never enters the server's image or process), `curate update --every 7` downloading EDRDG's files of the day (`JMdict_e_examp.gz`, `kanjidic2.xml.gz`; the JLPT lists are static, in the curator's `data/`), converting them and renaming each over its old file in the shared volume, which the server mounts read-only. A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`CardContent.entry`, card schema 6), so loading a newer version never touches cards. **Corrections reach cards by offer** (decided 2026-10-09): every entry has a `version` (16 hex digits of the SHA-256 of what cards take of it, made by the loader), a card keeps the one it took (`entryVersion`, card schema 8), and `DictionaryEndpoint.changed` returns, of the entries named with the versions cards keep, those that are another now. The card browser says how many cards are behind (`DictionaryCorrections`, `DictionaryUpdates`) and takes the corrections at a tap: `CardRepository.takeDictionaryUpdate` writes the entry as it is now over the revision in which the card last took it, as a revision of its own beside whatever came after, so the revisions merge per side as two devices' edits do (ADR 0003): a side only the dictionary changed takes the correction, a side the person changed too is a conflict settled in the composer. A card made before versions is never offered one. Every file carries its credits, which the app shows (About, and at the foot of the dictionary). In the app, a studyset's dictionary follows from its front language (`StudysetDictionary`, through `StudysetLanguageScope.of`): no attaching or detaching, and nothing dictionary-shaped shows without one (Add material › From the dictionary; Look up beside a vocabulary card's front). A word's example sentences (Tatoeba, through JMdict's examples edition) become its card's hints, up to three, the word in bold; Look up gives them to a card without hints of its own. `DictionaryAdder` makes the cards, each entry once per studyset, a word tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`); Look up in the composer gives a card the same tags (`DictionaryItem.tagNames`), and an entry looked up again on that card swaps the tags the one before brought (`isDictionaryTagName`). Where the instance also holds the language's characters, the dictionary screen has a tab for them (Kanji & kana: the kana, the JLPT levels and the school grades to add whole, each under its name), a character becoming a character card tagged `JLPT-NX`, `Grade N` or `Secondary school`, and `Hiragana` or `Katakana`; a character card's Look up searches the characters.
- **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`.
- **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). **A character card** (`CardType.character`, card schema 7, decided 2026-10-09: one type for kanji and kana) holds the character on its front, its `meanings` (a kana's romaji is its meaning, so kana are studied and written as kanji are) and a kanji's `onReadings` and `kunReadings` as dictionaries write them (katakana; hiragana with the okurigana after a dot); its JLPT level and school grade are tags, its strokes the app's own. **How characters are written** (`feature/kanji/`, decided 2026-10-09: bundled, so writing works offline) is KanjiVG's, in `assets/kanjivg/` (CC BY-SA 3.0, its notice in the licence list): the curator's `curate kanjivg` writes one gzipped JSON file per 256 code points (2.7 MB in all), which `StrokeRepository` (app-wide in `Dependencies`, as the maps) reads when a character of it is first asked for; the strokes are SVG path data in a 109 × 109 square, read with `path_parsing` (pure Dart, so `CharacterStrokes` flattens them into points without `dart:ui`). `StrokeOrderView` traces them in order; `showCharacterSheet` shows a character with its stroke order, meanings, readings and components, from a studied character's answer and from the dictionary's characters. **Writing by hand** (decided 2026-10-09, after Kanji Dojo's practice): the study option Write by hand (`StudyOptions.writeByHand`, settings schema 7) has character cards and words written in the session, mode `write` on a schedule of its own (words so written are neither quizzed nor typed). `WritingCanvas` takes raw pointers and claims every touch on it at once (an `EagerGestureRecognizer`), so a stroke never scrolls or swipes to rate; each stroke is checked as it ends (`checkStroke`, `kanji/model/stroke_check.dart`: both resampled to 22 points, shape compared once centred, position, length and direction, against what a loose hand strays by; a stroke closer to one still to come is out of order), a right one drawn as KanjiVG draws it, and the hand's offset from the middle forgiven in the strokes after. Mistakes are strokes that didn't count and hints asked for; two misses in a row show the next stroke faintly. `WritingBody` writes a word character by character, filling in those without a stroke order, and once written (or given up on, Show it) outlines the rating the mistakes suggest (`suggestedRating`: none Good, one or two OK, more Bad; given up, Bad). `stroke_check_test.dart` holds the checker to real strokes, moved, wavering, larger and smaller, reversed and out of order: change its weights only with that test passing.

View file

@ -58,7 +58,7 @@ services:
FILE_LIMIT_ANONYMOUS_MB: ${FILE_LIMIT_ANONYMOUS_MB:-}
FILE_LIMIT_IDENTIFIED_MB: ${FILE_LIMIT_IDENTIFIED_MB:-}
FILE_LIMIT_INSTANCE_MB: ${FILE_LIMIT_INSTANCE_MB:-}
# The dictionary files it loads at start and rereads daily, those that changed
# The dictionary files it loads at start and rereads hourly, those that changed
# (see the server's README): those the dictionaries service keeps current, read
# only. To supply files yourself instead, remove that service and mount
# ./dictionaries here (`- ./dictionaries:/app/dictionaries:ro`).

View file

@ -300,6 +300,26 @@ class EndpointDictionary extends _isc.EndpointRef {
'limit': limit,
},
);
/// The entries of the dictionary of [source] with meanings in
/// [glossLanguage] that changed since cards were made from them: of
/// [versions] (an entry's id and the version a card keeps of it, at most
/// [maxCompared]), those whose version is another now. An entry gone
/// from the source isn't among them; nor is one loaded before versions,
/// until its source is loaded again.
_ida.Future<List<_iqbqiphu.DictionaryHit>> changed(
String source,
String glossLanguage,
Map<String, String> versions,
) => caller.callServerEndpoint<List<_iqbqiphu.DictionaryHit>>(
'dictionary',
'changed',
{
'source': source,
'glossLanguage': glossLanguage,
'versions': versions,
},
);
}
/// What this instance offers. Public: the app asks before anyone is signed in.

View file

@ -32,7 +32,8 @@ abstract class DictionaryEntry
required this.searchKeys,
required this.looseKeys,
required this.entry,
});
String? version,
}) : version = version ?? '';
factory DictionaryEntry({
int? id,
@ -47,6 +48,7 @@ abstract class DictionaryEntry
required String searchKeys,
required String looseKeys,
required String entry,
String? version,
}) = _DictionaryEntryImpl;
factory DictionaryEntry.fromJson(Map<String, dynamic> jsonSerialization) {
@ -63,6 +65,7 @@ abstract class DictionaryEntry
searchKeys: jsonSerialization['searchKeys'] as String,
looseKeys: jsonSerialization['looseKeys'] as String,
entry: jsonSerialization['entry'] as String,
version: jsonSerialization['version'] as String?,
);
}
@ -106,6 +109,11 @@ abstract class DictionaryEntry
/// The entry as cards take it: its JSON, as the file holds it.
String entry;
/// A digest of what cards take of it (headword, reading, entry): a card
/// keeps the one it was made from, and a different one means the source
/// corrected the entry since. Empty until the source is loaded again.
String version;
/// Returns a shallow copy of this [DictionaryEntry]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -122,6 +130,7 @@ abstract class DictionaryEntry
String? searchKeys,
String? looseKeys,
String? entry,
String? version,
});
@override
Map<String, dynamic> toJson() {
@ -139,6 +148,7 @@ abstract class DictionaryEntry
'searchKeys': searchKeys,
'looseKeys': looseKeys,
'entry': entry,
'version': version,
};
}
@ -158,6 +168,7 @@ abstract class DictionaryEntry
'searchKeys': searchKeys,
'looseKeys': looseKeys,
'entry': entry,
'version': version,
};
}
@ -183,6 +194,7 @@ class _DictionaryEntryImpl extends DictionaryEntry {
required String searchKeys,
required String looseKeys,
required String entry,
String? version,
}) : super._(
id: id,
source: source,
@ -196,6 +208,7 @@ class _DictionaryEntryImpl extends DictionaryEntry {
searchKeys: searchKeys,
looseKeys: looseKeys,
entry: entry,
version: version,
);
/// Returns a shallow copy of this [DictionaryEntry]
@ -215,6 +228,7 @@ class _DictionaryEntryImpl extends DictionaryEntry {
String? searchKeys,
String? looseKeys,
String? entry,
String? version,
}) {
return DictionaryEntry(
id: id is int? ? id : this.id,
@ -229,6 +243,7 @@ class _DictionaryEntryImpl extends DictionaryEntry {
searchKeys: searchKeys ?? this.searchKeys,
looseKeys: looseKeys ?? this.looseKeys,
entry: entry ?? this.entry,
version: version ?? this.version,
);
}
}

View file

@ -24,6 +24,7 @@ abstract class DictionaryHit
this.reading,
required this.labels,
required this.entry,
this.version,
});
factory DictionaryHit({
@ -33,6 +34,7 @@ abstract class DictionaryHit
String? reading,
required List<String> labels,
required String entry,
String? version,
}) = _DictionaryHitImpl;
factory DictionaryHit.fromJson(Map<String, dynamic> jsonSerialization) {
@ -45,6 +47,7 @@ abstract class DictionaryHit
jsonSerialization['labels'],
),
entry: jsonSerialization['entry'] as String,
version: jsonSerialization['version'] as String?,
);
}
@ -65,6 +68,10 @@ abstract class DictionaryHit
/// The entry as cards take it, as JSON (the curator's README).
String entry;
/// A digest of what cards take of it; a card keeps it, to know when the
/// entry was corrected since. Null from a server older than it.
String? version;
/// Returns a shallow copy of this [DictionaryHit]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@ -75,6 +82,7 @@ abstract class DictionaryHit
String? reading,
List<String>? labels,
String? entry,
String? version,
});
@override
Map<String, dynamic> toJson() {
@ -86,6 +94,7 @@ abstract class DictionaryHit
if (reading != null) 'reading': reading,
'labels': labels.toJson(),
'entry': entry,
if (version != null) 'version': version,
};
}
@ -99,6 +108,7 @@ abstract class DictionaryHit
if (reading != null) 'reading': reading,
'labels': labels.toJson(),
'entry': entry,
if (version != null) 'version': version,
};
}
@ -118,6 +128,7 @@ class _DictionaryHitImpl extends DictionaryHit {
String? reading,
required List<String> labels,
required String entry,
String? version,
}) : super._(
source: source,
sourceId: sourceId,
@ -125,6 +136,7 @@ class _DictionaryHitImpl extends DictionaryHit {
reading: reading,
labels: labels,
entry: entry,
version: version,
);
/// Returns a shallow copy of this [DictionaryHit]
@ -138,6 +150,7 @@ class _DictionaryHitImpl extends DictionaryHit {
Object? reading = _Undefined,
List<String>? labels,
String? entry,
Object? version = _Undefined,
}) {
return DictionaryHit(
source: source ?? this.source,
@ -146,6 +159,7 @@ class _DictionaryHitImpl extends DictionaryHit {
reading: reading is String? ? reading : this.reading,
labels: labels ?? this.labels.map((e0) => e0).toList(),
entry: entry ?? this.entry,
version: version is String? ? version : this.version,
);
}
}

View file

@ -2208,6 +2208,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
.toList()
as T;
}
if (t == Map<String, String>) {
return (data as Map).map(
(k, v) => MapEntry(deserialize<String>(k), deserialize<String>(v)),
)
as T;
}
try {
return _iaic.Protocol().deserialize<T>(data, t);
} on _isc.DeserializationTypeNotFoundException catch (_) {}

View file

@ -491,6 +491,7 @@
"cardReport": "Meldung",
"cardSenses": "Wortbedeutungen",
"cardDictionaryEntry": "Wörterbucheintrag",
"cardDictionaryVersion": "Wörterbuchversion",
"studysetFromDictionary": "Aus dem Wörterbuch",
"dictionaryTitle": "Wörterbuch",
"dictionarySearchHint": "Ein Wort in Kanji, Kana oder Romaji, oder eine Bedeutung",
@ -537,6 +538,14 @@
"@dictionaryConfirmCharactersTitle": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharacterCredits": "Schriftzeichen aus {sources}.",
"@dictionaryCharacterCredits": {"placeholders": {"sources": {"type": "String"}}},
"dictionaryUpdatesBehind": "{count, plural, =1{1 Karteikarte ist hinter dem Wörterbuch zurück: Ihr Eintrag wurde seitdem korrigiert.} other{{count} Karteikarten sind hinter dem Wörterbuch zurück: Ihre Einträge wurden seitdem korrigiert.}}",
"@dictionaryUpdatesBehind": {"placeholders": {"count": {"type": "int"}}},
"dictionaryUpdatesTake": "Korrekturen übernehmen",
"dictionaryUpdatesTaken": "{count, plural, =1{1 Karteikarte aktualisiert.} other{{count} Karteikarten aktualisiert.}}",
"@dictionaryUpdatesTaken": {"placeholders": {"count": {"type": "int"}}},
"dictionaryUpdatesToSettle": "{count, plural, =1{Eine hat dort auch eine Änderung von dir: Öffne sie, um zu wählen.} other{{count} haben dort auch eine Änderung von dir: Öffne sie, um zu wählen.}}",
"@dictionaryUpdatesToSettle": {"placeholders": {"count": {"type": "int"}}},
"dictionaryUpdatesFailed": "Die Korrekturen konnten nicht übernommen werden. Versuche es erneut.",
"kanjiStrokeOrder": "Strichfolge",
"kanjiPlayAgain": "Noch einmal abspielen",
"kanjiNoStrokes": "Für dieses Schriftzeichen gibt es keine Strichfolge.",
@ -637,7 +646,7 @@
"composerDiscardTitle": "{count, plural, =1{Nicht gespeicherte Karteikarte verwerfen?} other{Nicht gespeicherte Karteikarten verwerfen?}}",
"composerDiscardMessage": "{count, plural, =1{1 Karteikarte wurde noch nicht gespeichert. Wenn du jetzt gehst, wird sie verworfen.} other{{count} Karteikarten wurden noch nicht gespeichert. Wenn du jetzt gehst, werden sie verworfen.}}",
"composerDiscardConfirm": "Verwerfen",
"composerConflict": "Diese Karteikarte wurde auf zwei Geräten unterschiedlich geändert. Wähle unten für jeden Teil die Version, die du behalten möchtest.",
"composerConflict": "Diese Karteikarte wurde auf zwei Arten zugleich geändert: auf zwei deiner Geräte oder von dir und durch eine Korrektur im Wörterbuch. Wähle unten für jeden Teil die Version, die du behalten möchtest.",
"composerKeepThis": "Diese behalten",
"formatAlignLeft": "Linksbündig",
"formatAlignCenter": "Zentriert",

View file

@ -539,6 +539,7 @@
"cardReport": "Report",
"cardSenses": "Senses",
"cardDictionaryEntry": "Dictionary entry",
"cardDictionaryVersion": "Dictionary version",
"studysetFromDictionary": "From the dictionary",
"dictionaryTitle": "Dictionary",
"dictionarySearchHint": "A word in kanji, kana or romaji, or a meaning",
@ -593,6 +594,14 @@
"@dictionaryConfirmCharactersTitle": {"placeholders": {"count": {"type": "int"}}},
"dictionaryCharacterCredits": "Characters from {sources}.",
"@dictionaryCharacterCredits": {"placeholders": {"sources": {"type": "String"}}},
"dictionaryUpdatesBehind": "{count, plural, =1{1 card is behind the dictionary: its entry was corrected since it was made.} other{{count} cards are behind the dictionary: their entries were corrected since they were made.}}",
"@dictionaryUpdatesBehind": {"placeholders": {"count": {"type": "int"}}},
"dictionaryUpdatesTake": "Take the corrections",
"dictionaryUpdatesTaken": "{count, plural, =1{1 card updated.} other{{count} cards updated.}}",
"@dictionaryUpdatesTaken": {"placeholders": {"count": {"type": "int"}}},
"dictionaryUpdatesToSettle": "{count, plural, =1{One has a change of yours there too: open it to choose.} other{{count} have a change of yours there too: open them to choose.}}",
"@dictionaryUpdatesToSettle": {"placeholders": {"count": {"type": "int"}}},
"dictionaryUpdatesFailed": "Couldn't take the corrections. Try again.",
"kanjiStrokeOrder": "Stroke order",
"kanjiPlayAgain": "Play again",
"kanjiNoStrokes": "There's no stroke order for this character.",
@ -699,7 +708,7 @@
"composerDiscardMessage": "{count, plural, =1{1 flashcard hasn't been saved yet. Leaving now will discard it.} other{{count} flashcards haven't been saved yet. Leaving now will discard them.}}",
"@composerDiscardMessage": {"placeholders": {"count": {"type": "int"}}},
"composerDiscardConfirm": "Discard",
"composerConflict": "This flashcard was changed differently on two devices. Choose the version to keep of each part below.",
"composerConflict": "This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary. Choose the version to keep of each part below.",
"composerKeepThis": "Keep this",
"formatAlignLeft": "Align left",
"formatAlignCenter": "Centre",

View file

@ -2968,6 +2968,12 @@ abstract class AppLocalizations {
/// **'Dictionary entry'**
String get cardDictionaryEntry;
/// No description provided for @cardDictionaryVersion.
///
/// In en, this message translates to:
/// **'Dictionary version'**
String get cardDictionaryVersion;
/// No description provided for @studysetFromDictionary.
///
/// In en, this message translates to:
@ -3208,6 +3214,36 @@ abstract class AppLocalizations {
/// **'Characters from {sources}.'**
String dictionaryCharacterCredits(String sources);
/// No description provided for @dictionaryUpdatesBehind.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 card is behind the dictionary: its entry was corrected since it was made.} other{{count} cards are behind the dictionary: their entries were corrected since they were made.}}'**
String dictionaryUpdatesBehind(int count);
/// No description provided for @dictionaryUpdatesTake.
///
/// In en, this message translates to:
/// **'Take the corrections'**
String get dictionaryUpdatesTake;
/// No description provided for @dictionaryUpdatesTaken.
///
/// In en, this message translates to:
/// **'{count, plural, =1{1 card updated.} other{{count} cards updated.}}'**
String dictionaryUpdatesTaken(int count);
/// No description provided for @dictionaryUpdatesToSettle.
///
/// In en, this message translates to:
/// **'{count, plural, =1{One has a change of yours there too: open it to choose.} other{{count} have a change of yours there too: open them to choose.}}'**
String dictionaryUpdatesToSettle(int count);
/// No description provided for @dictionaryUpdatesFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t take the corrections. Try again.'**
String get dictionaryUpdatesFailed;
/// No description provided for @kanjiStrokeOrder.
///
/// In en, this message translates to:
@ -3793,7 +3829,7 @@ abstract class AppLocalizations {
/// No description provided for @composerConflict.
///
/// In en, this message translates to:
/// **'This flashcard was changed differently on two devices. Choose the version to keep of each part below.'**
/// **'This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary. Choose the version to keep of each part below.'**
String get composerConflict;
/// No description provided for @composerKeepThis.

View file

@ -1755,6 +1755,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get cardDictionaryEntry => 'Wörterbucheintrag';
@override
String get cardDictionaryVersion => 'Wörterbuchversion';
@override
String get studysetFromDictionary => 'Aus dem Wörterbuch';
@ -1949,6 +1952,48 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Schriftzeichen aus $sources.';
}
@override
String dictionaryUpdatesBehind(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count Karteikarten sind hinter dem Wörterbuch zurück: Ihre Einträge wurden seitdem korrigiert.',
one: '1 Karteikarte ist hinter dem Wörterbuch zurück: Ihr Eintrag wurde seitdem korrigiert.',
);
return '$_temp0';
}
@override
String get dictionaryUpdatesTake => 'Korrekturen übernehmen';
@override
String dictionaryUpdatesTaken(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count Karteikarten aktualisiert.',
one: '1 Karteikarte aktualisiert.',
);
return '$_temp0';
}
@override
String dictionaryUpdatesToSettle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count haben dort auch eine Änderung von dir: Öffne sie, um zu wählen.',
one: 'Eine hat dort auch eine Änderung von dir: Öffne sie, um zu wählen.',
);
return '$_temp0';
}
@override
String get dictionaryUpdatesFailed =>
'Die Korrekturen konnten nicht übernommen werden. Versuche es erneut.';
@override
String get kanjiStrokeOrder => 'Strichfolge';
@ -2287,7 +2332,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get composerConflict =>
'Diese Karteikarte wurde auf zwei Geräten unterschiedlich geändert. Wähle unten für jeden Teil die Version, die du behalten möchtest.';
'Diese Karteikarte wurde auf zwei Arten zugleich geändert: auf zwei deiner Geräte oder von dir und durch eine Korrektur im Wörterbuch. Wähle unten für jeden Teil die Version, die du behalten möchtest.';
@override
String get composerKeepThis => 'Diese behalten';

View file

@ -1745,6 +1745,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get cardDictionaryEntry => 'Dictionary entry';
@override
String get cardDictionaryVersion => 'Dictionary version';
@override
String get studysetFromDictionary => 'From the dictionary';
@ -1938,6 +1941,47 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Characters from $sources.';
}
@override
String dictionaryUpdatesBehind(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'$count cards are behind the dictionary: their entries were corrected since they were made.',
one: '1 card is behind the dictionary: its entry was corrected since it was made.',
);
return '$_temp0';
}
@override
String get dictionaryUpdatesTake => 'Take the corrections';
@override
String dictionaryUpdatesTaken(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count cards updated.',
one: '1 card updated.',
);
return '$_temp0';
}
@override
String dictionaryUpdatesToSettle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count have a change of yours there too: open them to choose.',
one: 'One has a change of yours there too: open it to choose.',
);
return '$_temp0';
}
@override
String get dictionaryUpdatesFailed =>
'Couldn\'t take the corrections. Try again.';
@override
String get kanjiStrokeOrder => 'Stroke order';
@ -2273,7 +2317,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get composerConflict =>
'This flashcard was changed differently on two devices. Choose the version to keep of each part below.';
'This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary. Choose the version to keep of each part below.';
@override
String get composerKeepThis => 'Keep this';

View file

@ -22,10 +22,12 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
// dictionaries write them). Schema 6 added the dictionary entry a word was
// made from (`dictionary`: {source, id}), left out for a word of its own.
// Schema 7 added the character card (type `character`) and a kanji's
// readings, `on` and `kun`, each left out while empty.
// readings, `on` and `kun`, each left out while empty. Schema 8 added the
// version of the dictionary entry the card took last (`version` in
// `dictionary`), left out for one made before versions.
/// The schema this app writes.
const cardSchemaVersion = 7;
const cardSchemaVersion = 8;
/// Encodes a card's content into a revision's payload; [origin] names where
/// an imported card came from, in its first revision.
@ -70,7 +72,11 @@ ByteData encodeCardPayload(CardContent content, {String? origin}) =>
'at': report.at.toUtc().toIso8601String(),
},
if (content.entry case (:final source, :final id))
'dictionary': {'source': source, 'id': id},
'dictionary': {
'source': source,
'id': id,
'version': ?content.entryVersion,
},
if (content.senses.isNotEmpty)
'senses': [
for (final sense in content.senses)
@ -147,6 +153,10 @@ CardContent decodeCardPayload(ByteData payload) {
),
_ => throw const FormatException('A card has a bad dictionary entry.'),
},
entryVersion: switch (json['dictionary']) {
{'version': final String version} => version,
_ => null,
},
);
}
throw const FormatException('Not a card payload.');

View file

@ -20,6 +20,19 @@ typedef CardStamp = ({
bool imported,
});
/// What taking a dictionary's correction into a card came to.
enum UpdateTaken() {
/// Nothing: the card can't take it.
none,
/// Taken whole.
taken,
/// Taken, but the person changed a side the correction changed too: a
/// conflict to settle.
toSettle,
}
/// The person's flashcards: content kept as append-only revisions (ADR 0001,
/// rule 2), merged per side when devices edited offline, ordered by a
/// fractional index (decided 2026-09-30).
@ -221,6 +234,53 @@ class CardRepository(final CardGateway _gateway) {
return id;
}
/// Writes what [update] makes of the card [cardId] as it was when it last
/// took its dictionary entry: the first of its revisions with the entry
/// and version it has now. The new revision replaces that one alone, so
/// whatever changed on the card since stays a head of its own and merges
/// with the update per side (ADR 0003): a side only the dictionary
/// changed takes the correction, one changed both ways is a conflict the
/// person settles. Says which it came to; [UpdateTaken.none], writing
/// nothing, for a card that has no such revision (one made before
/// versions, or none from a dictionary).
Future<UpdateTaken> takeDictionaryUpdate(
String cardId,
CardContent Function(CardContent base) update,
) async {
// Revision ids sort by when they were written.
final revisions = [
for (final row in await _gateway.revisionsOf({cardId})) ?_decode(row),
]..sort((a, b) => a.id.compareTo(b.id));
final now = mergeRevisions(revisions)?.content;
final entry = now?.entry;
final version = now?.entryVersion;
if (entry == null || version == null) {
return UpdateTaken.none;
}
final base = revisions
.where(
(revision) =>
revision.content.entry == entry &&
revision.content.entryVersion == version,
)
.firstOrNull;
if (base == null) {
return UpdateTaken.none;
}
final id = await saveEdit(
cardId,
update(base.content),
basedOn: {base.id},
);
final merged = mergeRevisions([
...revisions,
CardRevision(id: id, parents: {base.id}, content: update(base.content)),
]);
return (merged?.conflicts.isEmpty ?? true)
? UpdateTaken.taken
: UpdateTaken.toSettle;
}
/// The current heads of the card [cardId]: what an edit of it as it is
/// now replaces. Empty for a card not on this device.
Future<Set<String>> headsOf(String cardId) async {

View file

@ -75,6 +75,9 @@ enum CardField() {
/// A kanji's kun readings, Japanese.
kunReadings,
/// The version of the dictionary entry the card took last.
entryVersion,
}
/// What a report says is wrong with a card.
@ -248,6 +251,12 @@ final class const CardContent({
/// A kanji's kun readings, in hiragana, the okurigana after a dot as
/// dictionaries write them (`た.べる`).
final List<String> kunReadings = const [],
/// The version of the dictionary entry ([entry]) the card took last, as
/// the instance gives it: another version there means the source
/// corrected the entry since. Null for a card of its own, or made before
/// versions.
final String? entryVersion,
}) with Equatable implements MergeableContent<CardField, CardContent> {
/// A front/back card with nothing on either side.
factory empty() =>
@ -274,6 +283,7 @@ final class const CardContent({
CardField.entry => entry,
CardField.onReadings => onReadings,
CardField.kunReadings => kunReadings,
CardField.entryVersion => entryVersion,
};
@override
@ -302,6 +312,9 @@ final class const CardContent({
kunReadings: field == CardField.kunReadings
? value! as List<String>
: kunReadings,
entryVersion: field == CardField.entryVersion
? value as String?
: entryVersion,
);
/// This content with some fields replaced; [withField] clears the map, the
@ -321,6 +334,7 @@ final class const CardContent({
DictionaryRef? entry,
List<String>? onReadings,
List<String>? kunReadings,
String? entryVersion,
}) => CardContent(
type: type ?? this.type,
front: front ?? this.front,
@ -338,6 +352,7 @@ final class const CardContent({
entry: entry ?? this.entry,
onReadings: onReadings ?? this.onReadings,
kunReadings: kunReadings ?? this.kunReadings,
entryVersion: entryVersion ?? this.entryVersion,
);
/// The fields [type] uses; the others stay empty. The page it was made
@ -389,6 +404,7 @@ final class const CardContent({
},
report: report,
entry: entry,
entryVersion: entryVersion,
);
for (final field in kept) {
content = content.withField(field, this[field]);
@ -521,6 +537,7 @@ final class const CardContent({
entry,
onReadings,
kunReadings,
entryVersion,
];
}

View file

@ -1,7 +1,6 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -24,6 +23,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/model/card_browser.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_browser_sheets.dart';
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/widget/dictionary_corrections.dart';
import 'package:halkyon_learn_flutter/src/feature/study/model/study_queue.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -358,6 +358,11 @@ class _BrowserState() extends State<_Browser> {
state: state,
actions: expanded ? _actions(state) : null,
),
// Keyed: rows coming and going above don't make it anew.
DictionaryCorrections(
key: const ValueKey('corrections'),
cards: [for (final each in state.cards) each.card],
),
const Divider(height: 1),
Expanded(
child: state.shown.isEmpty

View file

@ -175,6 +175,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
// Its sentences as hints, unless the card has hints of its own.
hints: _sent.hints.isEmpty ? item.exampleHints : null,
entry: item.ref,
entryVersion: item.version,
),
DictionaryCharacter() => _sent.copyWith(
front: RichContent.plain(item.headword),
@ -182,6 +183,7 @@ class _CardDraftEditorState() extends State<CardDraftEditor> {
onReadings: item.onReadings,
kunReadings: item.kunReadings,
entry: item.ref,
entryVersion: item.version,
),
}.normalized;
// The fields start afresh with it, as with content from elsewhere.
@ -739,6 +741,7 @@ class const _Conflicts({required final CardDraft draft})
CardField.entry => l10n.cardDictionaryEntry,
CardField.onReadings => l10n.characterOnReadings,
CardField.kunReadings => l10n.characterKunReadings,
CardField.entryVersion => l10n.cardDictionaryVersion,
};
}

View file

@ -0,0 +1,117 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_updates.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// Which cards are behind their dictionary, and taking the corrections
/// into all of them at once. Without the instance, none are.
class DictionaryUpdatesBloc(final DictionaryUpdates _updates)
extends Bloc<DictionaryUpdatesEvent, DictionaryUpdatesState> {
this : super(const DictionaryUpdatesState()) {
on<DictionaryUpdatesEventChecked>(_onChecked);
on<DictionaryUpdatesEventTaken>(_onTaken);
}
/// The cards last asked about, by what decides it: a list that changed
/// otherwise (a card studied, tagged, moved) isn't asked about again.
Set<(String, DictionaryRef?, String?)>? _asked;
Future<void> _onChecked(
DictionaryUpdatesEventChecked event,
Emitter<DictionaryUpdatesState> emit,
) async {
final asked = {
for (final card in event.cards)
(card.id, card.content.entry, card.content.entryVersion),
};
if (_asked != null &&
asked.length == _asked!.length &&
asked.containsAll(_asked!)) {
return;
}
_asked = asked;
try {
emit(
DictionaryUpdatesState(
behind: await _updates.behind(event.cards, event.languages),
),
);
} on Object catch (error) {
log('Asking for corrections failed', name: 'Dictionary', error: error);
emit(const DictionaryUpdatesState());
}
}
Future<void> _onTaken(
DictionaryUpdatesEventTaken event,
Emitter<DictionaryUpdatesState> emit,
) async {
if (state.taking || state.behind.isEmpty) {
return;
}
emit(DictionaryUpdatesState(behind: state.behind, taking: true));
var taken = 0;
var toSettle = 0;
try {
for (final MapEntry(key: cardId, value: item) in state.behind.entries) {
switch (await _updates.take(cardId, item)) {
case UpdateTaken.taken:
taken++;
case UpdateTaken.toSettle:
taken++;
toSettle++;
case UpdateTaken.none:
break;
}
}
// The cards change, and are asked about again as they come.
_asked = null;
emit(
DictionaryUpdatesState(outcome: (taken: taken, toSettle: toSettle)),
);
} on Object catch (error) {
log('Taking corrections failed', name: 'Dictionary', error: error);
_asked = null;
emit(DictionaryUpdatesState(behind: state.behind, failed: true));
}
}
}
/// The cards behind their dictionary, by id, each with its entry as it is
/// now; whether the corrections are being taken; what taking them came to
/// (how many cards took them, how many of those have a conflict to
/// settle), and whether it failed: both just now, cleared by the next
/// state.
final class const DictionaryUpdatesState({
final Map<String, DictionaryItem> behind = const {},
final bool taking = false,
final ({int taken, int toSettle})? outcome,
final bool failed = false,
}) with Equatable {
@override
List<Object?> get props => [behind, taking, outcome, failed];
}
/// What happens to the cards behind their dictionary.
sealed class const DictionaryUpdatesEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The [cards] shown, of a studyset in [languages]: which are behind?
final class const DictionaryUpdatesEventChecked(
final List<Card> cards,
final StudysetLanguages languages,
) extends DictionaryUpdatesEvent {
@override
List<Object?> get props => [cards, languages];
}
/// The person takes the corrections into every card behind.
final class const DictionaryUpdatesEventTaken() extends DictionaryUpdatesEvent;

View file

@ -13,6 +13,13 @@ abstract interface class DictionaryGateway() {
/// How many entries of [dictionary] carry [label].
Future<int> countLabelled(Dictionary dictionary, String label);
/// Of the entries [versions] names (each id with the version a card
/// keeps of it), those [dictionary] corrected since: as they are now.
Future<List<DictionaryItem>> changed(
Dictionary dictionary,
Map<String, String> versions,
);
/// The entries of [dictionary] that carry [label], in its order: [limit]
/// of them after the first [offset].
Future<List<DictionaryItem>> labelled(

View file

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
@ -47,6 +49,32 @@ class DictionaryRepository(final DictionaryGateway _gateway) {
? Future.value(const [])
: _gateway.search(dictionary, query);
/// How many entries one request compares, when cards ask whether their
/// entries were corrected.
static const comparedAtOnce = 500;
/// Of the entries [versions] names (each id with the version a card
/// keeps of it), those [dictionary] corrected since, as they are now;
/// asked [comparedAtOnce] at a time.
Future<List<DictionaryItem>> changed(
Dictionary dictionary,
Map<String, String> versions,
) async {
final entries = versions.entries.toList();
return [
for (var at = 0; at < entries.length; at += comparedAtOnce)
...await _gateway.changed(
dictionary,
Map.fromEntries(
entries.sublist(
at,
math.min(at + comparedAtOnce, entries.length),
),
),
),
];
}
/// How many entries of [dictionary] carry [label] (`jlpt:n5`).
Future<int> countLabelled(Dictionary dictionary, String label) =>
_gateway.countLabelled(dictionary, label);

View file

@ -0,0 +1,67 @@
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/model/dictionary.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
/// Cards behind their dictionary: made from an entry the source corrected
/// since, and the corrections taken into them (decided 2026-10-09: by
/// offer, never by themselves).
class DictionaryUpdates(
final DictionaryRepository _dictionaries,
final CardRepository _cards,
) {
/// Of [cards], those made from an entry of the dictionaries of a
/// studyset in [languages] (its words and its characters) that the source
/// corrected since, by card id, each with its entry as it is now. Cards
/// made before versions aren't asked about.
Future<Map<String, DictionaryItem>> behind(
List<Card> cards,
StudysetLanguages languages,
) async {
final behind = <String, DictionaryItem>{};
for (final kind in DictionaryKind.values) {
final dictionary = await _dictionaries.dictionaryFor(
languages,
kind: kind,
);
if (dictionary == null) {
continue;
}
// Each entry's cards with the version each keeps.
final cardsOf = <String, List<(String, String)>>{};
for (final card in cards) {
if ((card.content.entry, card.content.entryVersion)
case (
(:final source, :final id)?,
final version?,
)
when source == dictionary.source) {
(cardsOf[id] ??= []).add((card.id, version));
}
}
final versions = {
for (final MapEntry(key: id, value: kept) in cardsOf.entries)
// Cards of one entry keeping different versions: one no entry
// has, so the entry comes back and each card is compared.
id: {for (final (_, version) in kept) version}.length == 1
? kept.first.$2
: '',
};
for (final item in await _dictionaries.changed(dictionary, versions)) {
for (final (cardId, version)
in cardsOf[item.ref.id] ?? const <(String, String)>[]) {
if (version != item.version) {
behind[cardId] = item;
}
}
}
}
return behind;
}
/// Takes the corrections of [item] into the card [cardId]
/// ([CardRepository.takeDictionaryUpdate]).
Future<UpdateTaken> take(String cardId, DictionaryItem item) =>
_cards.takeDictionaryUpdate(cardId, item.updated);
}

Some files were not shown because too many files have changed in this diff Show more