feat: a dictionary's corrections reach the cards made from it, by offer
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:
parent
25e01ad128
commit
44cee0d6c6
23 changed files with 12710 additions and 28 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -44,6 +44,19 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
|
|||
_item(dictionary, hit),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<DictionaryItem>> changed(
|
||||
Dictionary dictionary,
|
||||
Map<String, String> versions,
|
||||
) async => [
|
||||
for (final hit in await _client.dictionary.changed(
|
||||
dictionary.source,
|
||||
dictionary.glossLanguage,
|
||||
versions,
|
||||
))
|
||||
_item(dictionary, hit),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<int> countLabelled(Dictionary dictionary, String label) =>
|
||||
_client.dictionary.countLabelled(
|
||||
|
|
@ -78,12 +91,14 @@ class ServerpodDictionaryGateway(final protocol.Client _client)
|
|||
reading: hit.reading,
|
||||
labels: hit.labels,
|
||||
entry: hit.entry,
|
||||
version: hit.version,
|
||||
),
|
||||
DictionaryKind.characters => DictionaryCharacter.fromEntry(
|
||||
ref: (source: hit.source, id: hit.sourceId),
|
||||
headword: hit.headword,
|
||||
labels: hit.labels,
|
||||
entry: hit.entry,
|
||||
version: hit.version,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,9 +64,18 @@ sealed class const DictionaryItem() with Equatable {
|
|||
/// What it can be picked by: `common`, `jlpt:n5`, `grade:1`, …
|
||||
List<String> get labels;
|
||||
|
||||
/// The version of the entry, as the instance gives it: a card keeps it,
|
||||
/// and another one later means the source corrected the entry. Null from
|
||||
/// an instance older than versions.
|
||||
String? get version;
|
||||
|
||||
/// It as a card of its kind.
|
||||
CardContent get card;
|
||||
|
||||
/// [base], a card as it took this entry before, with what the entry says
|
||||
/// now in place of what it said then; the rest of the card as it was.
|
||||
CardContent updated(CardContent base);
|
||||
|
||||
/// The tags a card made from it gets, wherever it is made.
|
||||
List<String> get tagNames;
|
||||
|
||||
|
|
@ -92,6 +101,7 @@ final class const DictionaryWord({
|
|||
|
||||
/// Sentences using it, its first meaning's first.
|
||||
final List<WordExample> examples = const [],
|
||||
@override final String? version,
|
||||
}) extends DictionaryItem {
|
||||
/// The word from its [entry] as the dictionary file holds it (JSON).
|
||||
factory fromEntry({
|
||||
|
|
@ -100,6 +110,7 @@ final class const DictionaryWord({
|
|||
required String? reading,
|
||||
required List<String> labels,
|
||||
required String entry,
|
||||
String? version,
|
||||
}) {
|
||||
final json = jsonDecode(entry);
|
||||
final senses = json is Map<String, Object?> ? json['senses'] : null;
|
||||
|
|
@ -112,6 +123,7 @@ final class const DictionaryWord({
|
|||
headword: headword,
|
||||
reading: reading,
|
||||
labels: labels,
|
||||
version: version,
|
||||
senses: [
|
||||
if (senses is List)
|
||||
for (final sense in senses.whereType<Map<String, Object?>>())
|
||||
|
|
@ -169,8 +181,22 @@ final class const DictionaryWord({
|
|||
senses: senses,
|
||||
hints: exampleHints,
|
||||
entry: ref,
|
||||
entryVersion: version,
|
||||
).normalized;
|
||||
|
||||
/// [base] with this word's headword, reading and senses; its hints, even
|
||||
/// those its sentences gave, stay as they are.
|
||||
@override
|
||||
CardContent updated(CardContent base) => base
|
||||
.copyWith(
|
||||
front: RichContent.plain(headword),
|
||||
reading: reading ?? '',
|
||||
senses: senses,
|
||||
entry: ref,
|
||||
entryVersion: version,
|
||||
)
|
||||
.normalized;
|
||||
|
||||
/// Its first [examplesOnCard] sentences as a card's hints: each the
|
||||
/// Japanese, the word in it in bold, and below it the English.
|
||||
List<RichContent> get exampleHints => [
|
||||
|
|
@ -178,7 +204,15 @@ final class const DictionaryWord({
|
|||
];
|
||||
|
||||
@override
|
||||
List<Object?> get props => [ref, headword, reading, labels, senses, examples];
|
||||
List<Object?> get props => [
|
||||
ref,
|
||||
headword,
|
||||
reading,
|
||||
labels,
|
||||
senses,
|
||||
examples,
|
||||
version,
|
||||
];
|
||||
}
|
||||
|
||||
/// A character of a dictionary of characters: a kanji with its meanings,
|
||||
|
|
@ -209,6 +243,7 @@ final class const DictionaryCharacter({
|
|||
|
||||
/// Its place among the 2,500 most used in newspapers.
|
||||
final int? frequency,
|
||||
@override final String? version,
|
||||
}) extends DictionaryItem {
|
||||
/// The character from its [entry] as the dictionary file holds it (JSON).
|
||||
factory fromEntry({
|
||||
|
|
@ -216,6 +251,7 @@ final class const DictionaryCharacter({
|
|||
required String headword,
|
||||
required List<String> labels,
|
||||
required String entry,
|
||||
String? version,
|
||||
}) {
|
||||
final json = jsonDecode(entry);
|
||||
final fields = json is Map<String, Object?>
|
||||
|
|
@ -237,6 +273,7 @@ final class const DictionaryCharacter({
|
|||
grade: number(fields['grade']),
|
||||
strokes: number(fields['strokes']),
|
||||
frequency: number(fields['frequency']),
|
||||
version: version,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -269,8 +306,22 @@ final class const DictionaryCharacter({
|
|||
onReadings: onReadings,
|
||||
kunReadings: kunReadings,
|
||||
entry: ref,
|
||||
entryVersion: version,
|
||||
).normalized;
|
||||
|
||||
/// [base] with this character's meanings and readings.
|
||||
@override
|
||||
CardContent updated(CardContent base) => base
|
||||
.copyWith(
|
||||
front: RichContent.plain(headword),
|
||||
meanings: meaningsShown,
|
||||
onReadings: onReadings,
|
||||
kunReadings: kunReadings,
|
||||
entry: ref,
|
||||
entryVersion: version,
|
||||
)
|
||||
.normalized;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
ref,
|
||||
|
|
@ -283,6 +334,7 @@ final class const DictionaryCharacter({
|
|||
grade,
|
||||
strokes,
|
||||
frequency,
|
||||
version,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/bloc/dictionary_updates_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_updates.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_language_scope.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
// Material's Card widget would clash with the domain's Card.
|
||||
import 'package:material_ui/material_ui.dart' hide Card;
|
||||
|
||||
/// Says how many of [cards] are behind their dictionary (the source
|
||||
/// corrected their entries since), with a way to take the corrections into
|
||||
/// all of them; nothing while none are, or the instance can't be asked.
|
||||
/// Once taken, says how many took them, and how many have a change of the
|
||||
/// person's there too, to settle in the editor. A line below it.
|
||||
class const DictionaryCorrections({required final List<Card> cards, super.key})
|
||||
extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => BlocProvider(
|
||||
create: (context) => DictionaryUpdatesBloc(
|
||||
DictionaryUpdates(
|
||||
DependenciesScope.of(context).dictionaries,
|
||||
PersonScope.of(context).cards,
|
||||
),
|
||||
),
|
||||
child: _Corrections(cards: cards),
|
||||
);
|
||||
}
|
||||
|
||||
class const _Corrections({required final List<Card> cards})
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<_Corrections> createState() => _CorrectionsState();
|
||||
}
|
||||
|
||||
class _CorrectionsState() extends State<_Corrections> {
|
||||
void _check() => context.read<DictionaryUpdatesBloc>().add(
|
||||
DictionaryUpdatesEventChecked(
|
||||
widget.cards,
|
||||
StudysetLanguageScope.of(context),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_check();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(_Corrections oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(widget.cards, oldWidget.cards)) {
|
||||
_check();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return BlocConsumer<DictionaryUpdatesBloc, DictionaryUpdatesState>(
|
||||
listenWhen: (before, now) => now.outcome != null || now.failed,
|
||||
listener: (context, state) {
|
||||
final message = switch (state.outcome) {
|
||||
(:final taken, :final toSettle) => [
|
||||
l10n.dictionaryUpdatesTaken(taken),
|
||||
if (toSettle > 0) l10n.dictionaryUpdatesToSettle(toSettle),
|
||||
].join(' '),
|
||||
null => l10n.dictionaryUpdatesFailed,
|
||||
};
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
builder: (context, state) => state.behind.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Divider(height: 1),
|
||||
ColoredBox(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.bookCheck,
|
||||
size: 20,
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.dictionaryUpdatesBehind(state.behind.length),
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
TextButton(
|
||||
onPressed: state.taking
|
||||
? null
|
||||
: () => context.read<DictionaryUpdatesBloc>().add(
|
||||
const DictionaryUpdatesEventTaken(),
|
||||
),
|
||||
child: Text(l10n.dictionaryUpdatesTake),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -208,7 +208,7 @@ void main() {
|
|||
entry: (source: 'kanjidic', id: '食'),
|
||||
);
|
||||
|
||||
expect(cardSchemaVersion, 7);
|
||||
expect(cardSchemaVersion, greaterThanOrEqualTo(7));
|
||||
expect(decodeCardPayload(encodeCardPayload(content)), content);
|
||||
expect(
|
||||
decodePayload(
|
||||
|
|
@ -229,6 +229,33 @@ void main() {
|
|||
expect(before.kunReadings, isEmpty);
|
||||
});
|
||||
|
||||
test('a card keeps the version of the entry it took (schema 8); one from '
|
||||
'before has none', () {
|
||||
final content = CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: text('食べる'),
|
||||
back: RichContent.empty(),
|
||||
meanings: const ['to eat'],
|
||||
entry: (source: 'jmdict', id: '1358280'),
|
||||
entryVersion: '0123456789abcdef',
|
||||
);
|
||||
|
||||
expect(cardSchemaVersion, 8);
|
||||
expect(decodeCardPayload(encodeCardPayload(content)), content);
|
||||
expect(
|
||||
decodeCardPayload(
|
||||
bytes({
|
||||
'schemaVersion': 7,
|
||||
'type': 'vocabulary',
|
||||
'front': text('食べる').toJson(),
|
||||
'back': RichContent.empty().toJson(),
|
||||
'dictionary': {'source': 'jmdict', 'id': '1358280'},
|
||||
}),
|
||||
).entryVersion,
|
||||
isNull,
|
||||
);
|
||||
});
|
||||
|
||||
test('reads a report of a category it does not know as "other"', () {
|
||||
final content = decodeCardPayload(
|
||||
bytes({
|
||||
|
|
|
|||
|
|
@ -191,6 +191,114 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group("A dictionary's correction", () {
|
||||
/// The word 食べる, made from JMdict's entry at [version], meaning
|
||||
/// [glosses].
|
||||
CardContent taberu(String version, List<String> glosses) => CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: RichContent.plain('食べる'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'たべる',
|
||||
senses: [VocabularySense(glosses: glosses)],
|
||||
entry: (source: 'jmdict', id: '1358280'),
|
||||
entryVersion: version,
|
||||
).normalized;
|
||||
|
||||
/// The correction: the senses and version of [taberu].
|
||||
CardContent Function(CardContent) corrected(List<String> glosses) =>
|
||||
(base) => base
|
||||
.copyWith(
|
||||
senses: [VocabularySense(glosses: glosses)],
|
||||
entryVersion: 'v2',
|
||||
)
|
||||
.normalized;
|
||||
|
||||
test('goes whole into a card nobody changed since', () async {
|
||||
final id = await repository.create('japanese', taberu('v1', ['to eat']));
|
||||
|
||||
expect(
|
||||
await repository.takeDictionaryUpdate(
|
||||
id,
|
||||
corrected(['to eat', 'to consume']),
|
||||
),
|
||||
UpdateTaken.taken,
|
||||
);
|
||||
|
||||
final now = (await repository.cardsOf('japanese')).single;
|
||||
expect(now.content.meanings, ['to eat', 'to consume']);
|
||||
expect(now.content.entryVersion, 'v2');
|
||||
expect(now.heads, hasLength(1));
|
||||
});
|
||||
|
||||
test('merges with what the person changed on another side', () async {
|
||||
final id = await repository.create('japanese', taberu('v1', ['to eat']));
|
||||
final made = (await repository.cardsOf('japanese')).single;
|
||||
await repository.save(
|
||||
made,
|
||||
made.content.copyWith(hints: [RichContent.plain('ichidan')]),
|
||||
);
|
||||
|
||||
expect(
|
||||
await repository.takeDictionaryUpdate(
|
||||
id,
|
||||
corrected(['to eat', 'to consume']),
|
||||
),
|
||||
UpdateTaken.taken,
|
||||
);
|
||||
|
||||
final now = (await repository.cardsOf('japanese')).single;
|
||||
expect(now.content.meanings, ['to eat', 'to consume']);
|
||||
expect(now.content.hints, [RichContent.plain('ichidan')]);
|
||||
expect(now.hasConflict, isFalse);
|
||||
});
|
||||
|
||||
test(
|
||||
'is a conflict to settle where the person changed the same side',
|
||||
() async {
|
||||
final id = await repository.create(
|
||||
'japanese',
|
||||
taberu('v1', ['to eat']),
|
||||
);
|
||||
final made = (await repository.cardsOf('japanese')).single;
|
||||
await repository.save(
|
||||
made,
|
||||
made.content
|
||||
.copyWith(
|
||||
senses: [
|
||||
const VocabularySense(glosses: ['to dine']),
|
||||
],
|
||||
)
|
||||
.normalized,
|
||||
);
|
||||
|
||||
expect(
|
||||
await repository.takeDictionaryUpdate(
|
||||
id,
|
||||
corrected(['to eat', 'to consume']),
|
||||
),
|
||||
UpdateTaken.toSettle,
|
||||
);
|
||||
expect(
|
||||
(await repository.cardsOf('japanese')).single.conflicts.keys,
|
||||
contains(CardField.senses),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('is nothing to a card made before versions', () async {
|
||||
final id = await repository.create(
|
||||
'japanese',
|
||||
taberu('v1', ['to eat']).withField(CardField.entryVersion, null),
|
||||
);
|
||||
|
||||
expect(
|
||||
await repository.takeDictionaryUpdate(id, corrected(['to consume'])),
|
||||
UpdateTaken.none,
|
||||
);
|
||||
expect(gateway.written.where((row) => row.cardId == id), hasLength(1));
|
||||
});
|
||||
});
|
||||
|
||||
test('a card not on this device has no heads', () async {
|
||||
expect(await repository.headsOf('card-9999'), isEmpty);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_dictionary_gateway.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
|
@ -155,6 +156,76 @@ void main() {
|
|||
expect(answer, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('cards behind the dictionary say so, and take its '
|
||||
'corrections at a tap', (tester) async {
|
||||
tester.view.physicalSize = const Size(1400, 1600);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await cards.create(
|
||||
'words',
|
||||
CardContent(
|
||||
type: CardType.vocabulary,
|
||||
front: RichContent.plain('食べる'),
|
||||
back: RichContent.empty(),
|
||||
reading: 'たべる',
|
||||
senses: const [
|
||||
VocabularySense(glosses: ['to eat']),
|
||||
],
|
||||
entry: (source: 'jmdict', id: '1358280'),
|
||||
entryVersion: 'v1',
|
||||
).normalized,
|
||||
);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'words',
|
||||
encodeStudysetPayload(
|
||||
StudysetContent(
|
||||
title: 'Words',
|
||||
description: RichContent.empty(),
|
||||
frontLanguage: 'ja',
|
||||
backLanguage: 'en',
|
||||
),
|
||||
),
|
||||
),
|
||||
cardGateway: cardGateway,
|
||||
dictionaryGateway: FakeDictionaryGateway(
|
||||
words: [
|
||||
word(
|
||||
'1358280',
|
||||
'食べる',
|
||||
reading: 'たべる',
|
||||
glosses: ['to eat', 'to consume'],
|
||||
version: 'v2',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
location: RoutePaths.cardBrowser('words'),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.text(
|
||||
'1 card is behind the dictionary: its entry was corrected since it '
|
||||
'was made.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(find.text('Take the corrections'));
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('1 card updated.'), findsOneWidget);
|
||||
expect(find.text('Take the corrections'), findsNothing);
|
||||
final updated = (await cards.cardsOf('words')).single.content;
|
||||
expect(updated.meanings, ['to eat', 'to consume']);
|
||||
expect(updated.entryVersion, 'v2');
|
||||
});
|
||||
|
||||
testWidgets('cards chosen are deleted together, and Undo brings them '
|
||||
'back', (tester) async {
|
||||
await show(tester);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue