feat(app): tags keep their case; dictionary tags read JLPT-N5, Noun
A tag is still its name, but no longer in lowercase: the name keeps the case it was written in, and names that differ only in case are one tag (ADR 0004, rule 2, amended). Two such tags from two devices fold into the one with the smaller id, its case with it. Tags stored before stay lowercase until renamed. Words from the dictionary are tagged JLPT-N5 to JLPT-N1 and with their word class (Noun, Verb, i-Adjective, na-Adjective, ...). Anki tags and PDF chapter tags arrive as written. 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
02d6542cfa
commit
c20578e421
24 changed files with 148 additions and 95 deletions
|
|
@ -137,14 +137,14 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
|
|||
- **Serverpod's own helpers aren't guarded**: `ServerSideSessions.revokeSession` deletes by id alone, and `listSessions` without `authUserId` returns everyone's sessions. Never pass them anything a client sent.
|
||||
- **Granting a scope** goes through a function like `grantIdentified`, which updates the person *and* their session rows.
|
||||
- **Content envelope**: synced content tables carry `keyId` and `nonce` (nullable; `null` = plaintext, the only value, ADR 0002 rule 6) and `payload` (`ByteData`): UTF-8 JSON with a `schemaVersion`, encoded and decoded by one codec per feature (e.g. `studyset_payload.dart`) on top of `core/content/payload.dart`, which also holds the blanked `redactedPayload`. Content fields live inside `payload`: a new field raises the schema version instead of migrating a synced table, which would lock older app versions out of syncing (ADR 0002, rule 3). A payload in a newer schema is skipped with a log, never shown half-read.
|
||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name, ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||
- **Synced data** (`database: sync`, `serverpod_offline_sync`, ADR 0001): every person has their own local SQLite database (`<app support>/people/<authUserId>/sync.sqlite`). Its gateway writes through the `OfflineSyncDatabaseSession`, **names the columns of every update**, and calls `SyncService.requestSync()` after every write; the repository re-reads after its own writes and when a sync merged changes (`remoteChanges`). `SyncService` syncs on triggers (at start, 2 s after the last write, on resume, every 5 min), one sync at a time. **Deleting blanks the payload first** (ADR 0002, rule 2): the engine keeps deleted rows until the space is deleted. Since blanking can't be undone, an Undo comes before it: `StudysetDeletions`, opened with the person's data, keeps every studyset deletion that can still be undone; lists leave out what it reports as pending, and once the time to undo is over it deletes the whole subtree, what is inside first, each studyset's cards blanked before it. Widgets delete and undo through it directly (`deleteStudysetWithUndo`), not through a bloc: deleting a studyset from its own screen closes that screen and its bloc, and the deletion and its Undo must outlive them. It closes first with the person's data and drops what still waits. Tags differ, being values (a tag is its name whatever its case, which it keeps; ADR 0004 rule 2): one is blanked and deleted at once, and its Undo makes a tag of that name and colour anew, on the same cards. Order by the UUID v7 id (ADR 0001, rule 7). Access to synced rows is the engine's: a person syncs their personal space and the spaces granted to them; the endpoint rules above guard everything that isn't synced.
|
||||
- **Content people edit is append-only revisions** (ADR 0001 rule 2, ADR 0003; the reference is `CardRevision`): a revision is never updated except to blank it, and `parents` lists the revisions it replaces. A save's parents are all current heads, which is also how a conflict is resolved. `mergeRevisions` (`feature/card/model/card_merge.dart`) merges several heads per side against their newest common ancestor; a side changed differently is a conflict the person settles, never merged silently. Deleting blanks every revision, then deletes.
|
||||
- **Reviews are an append-only log** (`ReviewLog`: card, rating, `reviewedAt` in UTC, `durationMs`); taking back a rating deletes its row. Besides ratings it holds a fresh start (`reset`), which never counts as studying; older app versions skip a mode they don't know. A burial isn't in the log but on the card: `card.buriedAt` sets it aside until the next local day begins on the device looking, out of every queue and count (Unbury clears it), read with the cards every list reads anyway. A card's schedule is never stored: `scheduleOf` (`feature/study/model/card_schedule.dart`) replays its reviews in time order with FSRS-6 (`fsrs`, pinned exactly), and only the final due date is fuzzed, from the card's id and review count, so every device computes the same. Studying a studyset covers everything inside it but archived studysets (`studiedIn`), and the study queue (`study_queue.dart`) takes each studyset's cards as a group: due cards of all of them earliest first, then each studyset's new cards in tree order, within each studyset's daily limits of new cards and reviews (its study options, `StudysetStudyOptions` in its payload, schema 3; without, the person's new cards a day and no limit on reviews; cards in their learning steps come anyway), in the order the studied studyset's options ask for (reviews first, mixed, new first). The library's and the topics' counts and badges add up the same way. Deleting a card deletes its reviews.
|
||||
- **Order is a fractional index** (`fractional_indexing_dart`, rocicorp's algorithm) in its own column, last writer wins. Two devices appending at once can pick the same key, so ties sort by id.
|
||||
- **Tree, tags, modes, settings and files** (ADR 0004): a studyset's `parent` and sibling `position` form the tree, and studysets in a cycle or without their parent show at the top level (`topLevel`); its `archivedAt`, `starredAt` and `updatedAt` are columns, and every change of a studyset writes `updatedAt` with it (the library lists starred studysets first, then the most recently changed); a card's tags are `card_tag` rows, one per assignment, never updated, and a duplicate counts once; `review_log.mode` is `review`, `reverse` (its own schedule) or `cram` (never moves a schedule), and a schedule replays the reviews of one mode; `person_settings` is meant as one row per person, and extra rows fold into the one with the smallest id; an `asset` row stands for a file in private cloud storage, found by its hash. **Never derive a row's id from a person or anything else guessable**: ids are unique across the whole table, so someone could claim it first in their own space and break the owner's sync.
|
||||
- **Files** (ADR 0004, rule 5): `AssetEndpoint` (`requireLogin`) takes uploads into the caller's own space by SHA-256 (`startUpload` gives Serverpod's upload description, or null when the file is there already and claims it anew; `finishUpload` keeps it only if its content has that hash) and serves an asset row's file to its space's owner and members only, `NotFoundException` otherwise, also for a deleted row (the sync engine hides tombstones). Files live in the private database storage at `spaces/<space>/<sha256>`, at most 20 MB each, and within limits (`file_limits.dart`, from the environment: an anonymous person 100 MB, an identified one 500 MB or what a `file_allowance` row grants them, the instance 15 GB), checked when an upload starts (unfinished uploads count, or they could fill the disk unnoticed) and again when it finishes (only what is kept counts), refused with `FileLimitExceededException`; `deletePerson` deletes the person's files and allowance, and `FilePruningFutureCall` deletes daily those no live row refers to (or never finished), 7 days after they were last claimed. In the app, `AssetRepository` (`core/asset/`) keeps files by hash in the person's folder (`files/`, uploads waiting marked in `files/uploads/`), uploads after each sync that went through, and downloads on demand, checking the hash; every hash is checked to be one before it becomes part of a path. Content names a file by the URL `asset:<asset row id>` (`assetUrl`, `RichContent.assetIds`): images are the editor's `image` block, sounds an `audio` block (`core/editor/file_blocks.dart`), and any other URL is never fetched. `StoredFileImage` loads through the repository like Flutter's `NetworkImage`, keyed by the person's repository too, so no image crosses people.
|
||||
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the Files tab's default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
|
||||
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start, those whose SHA-256 changed, each source replaced in one transaction (`dictionary_loader.dart`). A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`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). `DictionaryAdder` makes the cards, each entry once per studyset, tagged `jlpt-nX` and with its word class by the first sense.
|
||||
- **Dictionaries** (decided 2026-10-08): reference data the server holds for everyone, owned by no one, never synced (`dictionary_entry`, `dictionary_source`, `lib/src/dictionary/`). `halkyon_learn_curator` (AGPL, no Serverpod, so the server never links its code) turns each source's download into one format, a dictionary file (its README is the contract), with an adapter per source; everything specific to a language (kana, romaji, meanings without "to") goes into each entry's `keys` and `looseKeys`, so the server's search knows no language: exact key, exact loose key, key starting with the query, loose key starting with it, each by the source's `rank` (`DictionaryEndpoint.search`, LIKE on barred keys, no extension needed). The server loads the files in its `dictionaries` folder at start, those whose SHA-256 changed, each source replaced in one transaction (`dictionary_loader.dart`). A card made from an entry copies what it needs and keeps `(source, sourceId)` as a soft reference only (`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). `DictionaryAdder` makes the cards, each entry once per studyset, tagged `JLPT-NX` and with its word class by the first sense (`Noun`, `Verb`, `na-Adjective`).
|
||||
- **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).
|
||||
|
|
|
|||
|
|
@ -47,10 +47,13 @@ revision's payload, and the per-side merge of ADR 0003 becomes per field.
|
|||
assignment, never updated. Removing a tag deletes one row, and adding it on another
|
||||
device at the same time makes another, so concurrent edits merge without losing
|
||||
either. A card with the same tag twice counts once. A tag is its name (added
|
||||
2026-10-01): names are lowercase, and a person has one tag per name. Two devices
|
||||
creating the same name offline make two rows; the app keeps the one with the
|
||||
smallest id and moves the other's cards onto it, as with settings (rule 4).
|
||||
Renaming a tag to another's name merges them the same way. The colour lives in the
|
||||
2026-10-01): a person has one tag per name, whatever its case, and the name keeps
|
||||
the case it was written in (amended 2026-10-09: names were lowercase until then,
|
||||
but tags such as `JLPT-N5` and `Noun` read as they are written; those stored
|
||||
before stay lowercase until renamed). Two devices creating the same name offline,
|
||||
in any case, make two rows; the app keeps the one with the smallest id, its case
|
||||
with it, and moves the other's cards onto it, as with settings (rule 4). Renaming
|
||||
a tag to another's name merges them the same way. The colour lives in the
|
||||
payload next to the name.
|
||||
3. **Reviews say how the card was studied.** `review_log.mode` is `review` (front to
|
||||
back), `reverse` (back to front, with a schedule of its own, as Anki's reverse cards
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ import 'package:serverpod_database/serverpod_database.dart' as _isd;
|
|||
|
||||
/// A tag a person gives cards (ADR 0004): a name to study and filter by. Synced; its
|
||||
/// space owns it. The name is content, so it lives in the payload, like a
|
||||
/// studyset's title. A tag is its name: a person has one tag per lowercase name, and
|
||||
/// two with the same name, made on two devices offline, fold into one.
|
||||
/// studyset's title. A tag is its name: a person has one tag per name, whatever its
|
||||
/// case, and two with the same name, made on two devices offline, fold into one.
|
||||
abstract class Tag
|
||||
implements _isd.TableRow<_isc.UuidValue?>, _isc.ProtocolSerialization {
|
||||
Tag._({
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@
|
|||
"tagSheetCreateTitle": "Tag erstellen",
|
||||
"tagSheetEditTitle": "Tag bearbeiten",
|
||||
"tagSheetNameLabel": "Name",
|
||||
"tagSheetNameHelper": "Wird klein geschrieben gespeichert und angezeigt.",
|
||||
"tagSheetNameHelper": "Groß- und Kleinschreibung ergibt keinen neuen Tag: „Nomen“ und „nomen“ sind einer.",
|
||||
"tagSheetNameExists": "Den Tag „{name}“ gibt es bereits.",
|
||||
"tagSheetNameMerges": "Es gibt bereits einen Tag „{name}“: Beim Speichern wird dieser mit ihm zusammengeführt.",
|
||||
"tagSheetColor": "Farbe",
|
||||
|
|
|
|||
|
|
@ -483,7 +483,7 @@
|
|||
"tagSheetCreateTitle": "Create tag",
|
||||
"tagSheetEditTitle": "Edit tag",
|
||||
"tagSheetNameLabel": "Name",
|
||||
"tagSheetNameHelper": "Stored and displayed in lowercase.",
|
||||
"tagSheetNameHelper": "Capitals don't make another tag: 'Noun' and 'noun' are one.",
|
||||
"tagSheetNameExists": "The tag '{name}' exists already.",
|
||||
"@tagSheetNameExists": {"placeholders": {"name": {"type": "String"}}},
|
||||
"tagSheetNameMerges": "There is a tag '{name}' already: saving merges this one into it.",
|
||||
|
|
|
|||
|
|
@ -2695,7 +2695,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @tagSheetNameHelper.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Stored and displayed in lowercase.'**
|
||||
/// **'Capitals don\'t make another tag: \'Noun\' and \'noun\' are one.'**
|
||||
String get tagSheetNameHelper;
|
||||
|
||||
/// No description provided for @tagSheetNameExists.
|
||||
|
|
|
|||
|
|
@ -1577,7 +1577,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
String get tagSheetNameHelper =>
|
||||
'Wird klein geschrieben gespeichert und angezeigt.';
|
||||
'Groß- und Kleinschreibung ergibt keinen neuen Tag: „Nomen“ und „nomen“ sind einer.';
|
||||
|
||||
@override
|
||||
String tagSheetNameExists(String name) {
|
||||
|
|
|
|||
|
|
@ -1566,7 +1566,8 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get tagSheetNameLabel => 'Name';
|
||||
|
||||
@override
|
||||
String get tagSheetNameHelper => 'Stored and displayed in lowercase.';
|
||||
String get tagSheetNameHelper =>
|
||||
'Capitals don\'t make another tag: \'Noun\' and \'noun\' are one.';
|
||||
|
||||
@override
|
||||
String tagSheetNameExists(String name) {
|
||||
|
|
|
|||
|
|
@ -171,9 +171,11 @@ class AnkiImporter(
|
|||
final names = {
|
||||
...note.tags,
|
||||
if (mode == AnkiImportMode.tagBySubdeck) ...below(note),
|
||||
}.map(canonicalTagName).where((name) => name.isNotEmpty);
|
||||
}.map(tidyTagName).where((name) => name.isNotEmpty);
|
||||
for (final name in names) {
|
||||
final tagId = tagIdOf[name] ??= await _tags.create(name);
|
||||
final tagId = tagIdOf[canonicalTagName(name)] ??= await _tags.create(
|
||||
name,
|
||||
);
|
||||
(tagsByCard[cardId] ??= {}).add(tagId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ typedef DictionaryAdded = ({int added, int skipped});
|
|||
|
||||
/// Adds dictionary words to a studyset as vocabulary cards, each once: a
|
||||
/// word a card of the studyset was made from already is left out. Each card
|
||||
/// is tagged with the word's JLPT level (`jlpt-n5`) and its word class
|
||||
/// (`verb`), by its first sense, so a level or a class can be studied alone;
|
||||
/// tags are lower case, as tag names are kept.
|
||||
/// is tagged with the word's JLPT level (`JLPT-N5`) and its word class
|
||||
/// (`Verb`), by its first sense, so a level or a class can be studied alone.
|
||||
class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
||||
/// The dictionary entries the cards of [studysetId] were made from.
|
||||
Future<Set<DictionaryRef>> entriesIn(String studysetId) async => {
|
||||
|
|
@ -52,7 +51,7 @@ class DictionaryAdder(final CardRepository _cards, final TagRepository _tags) {
|
|||
/// by its first sense (JMdict's codes, grouped), in English as the
|
||||
/// dictionary's codes are.
|
||||
List<String> tagNamesOf(DictionaryWord word) => [
|
||||
if (word.jlpt case final level?) 'jlpt-n$level',
|
||||
if (word.jlpt case final level?) 'JLPT-N$level',
|
||||
...{
|
||||
for (final code in word.senses.firstOrNull?.pos ?? const <String>[])
|
||||
?_wordClass(code),
|
||||
|
|
@ -60,16 +59,16 @@ List<String> tagNamesOf(DictionaryWord word) => [
|
|||
];
|
||||
|
||||
String? _wordClass(String code) => switch (code) {
|
||||
'n' || 'n-adv' || 'n-t' || 'n-pr' => 'noun',
|
||||
'pn' => 'pronoun',
|
||||
'adj-i' || 'adj-ix' => 'i-adjective',
|
||||
'adj-na' => 'na-adjective',
|
||||
'adv' || 'adv-to' => 'adverb',
|
||||
'prt' => 'particle',
|
||||
'conj' => 'conjunction',
|
||||
'int' => 'interjection',
|
||||
'ctr' => 'counter',
|
||||
'exp' => 'expression',
|
||||
_ when code.startsWith('v') && code != 'vi' && code != 'vt' => 'verb',
|
||||
'n' || 'n-adv' || 'n-t' || 'n-pr' => 'Noun',
|
||||
'pn' => 'Pronoun',
|
||||
'adj-i' || 'adj-ix' => 'i-Adjective',
|
||||
'adj-na' => 'na-Adjective',
|
||||
'adv' || 'adv-to' => 'Adverb',
|
||||
'prt' => 'Particle',
|
||||
'conj' => 'Conjunction',
|
||||
'int' => 'Interjection',
|
||||
'ctr' => 'Counter',
|
||||
'exp' => 'Expression',
|
||||
_ when code.startsWith('v') && code != 'vi' && code != 'vt' => 'Verb',
|
||||
_ => null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -13,25 +13,24 @@ const tagSchemaVersion = 1;
|
|||
/// A tag's content: its name and colour.
|
||||
typedef TagContent = ({String name, String colorName});
|
||||
|
||||
/// Encodes a tag's [content] into its payload, the name as a
|
||||
/// [canonicalTagName].
|
||||
/// Encodes a tag's [content] into its payload, the name as a [tidyTagName].
|
||||
ByteData encodeTagPayload(TagContent content) =>
|
||||
encodePayload(tagSchemaVersion, {
|
||||
'name': canonicalTagName(content.name),
|
||||
'name': tidyTagName(content.name),
|
||||
'color': content.colorName,
|
||||
});
|
||||
|
||||
/// Decodes a tag's payload, the name as a [canonicalTagName] whoever wrote
|
||||
/// it.
|
||||
/// Decodes a tag's payload, the name as a [tidyTagName] whoever wrote it.
|
||||
/// Names from before tags kept their case are in lowercase.
|
||||
///
|
||||
/// Throws a [FormatException] for anything else, including content written
|
||||
/// by a newer app version in a schema this one doesn't know.
|
||||
TagContent decodeTagPayload(ByteData payload) {
|
||||
final json = decodePayload(payload, newestSchema: tagSchemaVersion);
|
||||
if (json case {'name': final String name, 'color': final String color}) {
|
||||
final canonical = canonicalTagName(name);
|
||||
if (canonical.isNotEmpty) {
|
||||
return (name: canonical, colorName: color);
|
||||
final tidy = tidyTagName(name);
|
||||
if (tidy.isNotEmpty) {
|
||||
return (name: tidy, colorName: color);
|
||||
}
|
||||
}
|
||||
throw const FormatException('Not a tag payload.');
|
||||
|
|
|
|||
|
|
@ -21,10 +21,11 @@ typedef DeletedTag = ({
|
|||
/// read from this device, changed offline, and synced with their other
|
||||
/// devices in the background. They all share the tags.
|
||||
///
|
||||
/// A tag is its name (ADR 0004, rule 2). Two tags of one name, made on two
|
||||
/// devices offline or by renaming one, fold into the one with the smallest
|
||||
/// id: it gets the other's cards, notes and paths, and the other is deleted.
|
||||
/// Every device picks the same one, so they all end up alike.
|
||||
/// A tag is its name, whatever its case (ADR 0004, rule 2). Two tags of one
|
||||
/// name, made on two devices offline or by renaming one, fold into the one
|
||||
/// with the smallest id: it gets the other's cards, notes and paths, and the
|
||||
/// other is deleted, its case with it. Every device picks the same one, so
|
||||
/// they all end up alike.
|
||||
class TagRepository(
|
||||
final TagGateway _gateway, {
|
||||
|
||||
|
|
@ -68,13 +69,13 @@ class TagRepository(
|
|||
/// change.
|
||||
Future<Tagging> read() => _serially(_read);
|
||||
|
||||
/// The id of the tag named [name] (any case): an existing one, or a new
|
||||
/// one in [colorName]. Throws an [ArgumentError] for a blank name.
|
||||
/// The id of the tag named [name] in any case: an existing one, keeping
|
||||
/// its case, or a new one in [colorName]. Throws an [ArgumentError] for a blank name.
|
||||
Future<String> create(
|
||||
String name, {
|
||||
String colorName = defaultTagColorName,
|
||||
}) async {
|
||||
if (canonicalTagName(name).isEmpty) {
|
||||
if (tidyTagName(name).isEmpty) {
|
||||
throw ArgumentError.value(name, 'name', 'is blank');
|
||||
}
|
||||
final id = await _serially(() => _idOf(name, colorName));
|
||||
|
|
@ -89,7 +90,7 @@ class TagRepository(
|
|||
required String name,
|
||||
required String colorName,
|
||||
}) async {
|
||||
if (canonicalTagName(name).isEmpty) {
|
||||
if (tidyTagName(name).isEmpty) {
|
||||
throw ArgumentError.value(name, 'name', 'is blank');
|
||||
}
|
||||
await _serially(
|
||||
|
|
@ -311,7 +312,7 @@ class TagRepository(
|
|||
}
|
||||
}
|
||||
return Tagging(
|
||||
tags..sort((a, b) => a.name.compareTo(b.name)),
|
||||
tags..sort((a, b) => compareTagNames(a.name, b.name)),
|
||||
tagIdsByCard,
|
||||
tagIdsByNote,
|
||||
tagIdsByPath,
|
||||
|
|
@ -328,7 +329,7 @@ class TagRepository(
|
|||
) async {
|
||||
final byName = <String, List<Tag>>{};
|
||||
for (final tag in tags) {
|
||||
(byName[tag.name] ??= []).add(tag);
|
||||
(byName[canonicalTagName(tag.name)] ??= []).add(tag);
|
||||
}
|
||||
var folded = false;
|
||||
for (final sameName in byName.values.where((tags) => tags.length > 1)) {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ const defaultTagColorName = 'cobalt';
|
|||
|
||||
/// A tag cards can have, to study and filter by.
|
||||
///
|
||||
/// A tag is its name (ADR 0004, rule 2): names are [canonicalTagName]s, and a
|
||||
/// person has one tag per name. Content moving between people later will
|
||||
/// match tags by name too.
|
||||
/// A tag is its name (ADR 0004, rule 2): a person has one tag per name,
|
||||
/// whatever its case ([canonicalTagName]), and the name keeps the case it was
|
||||
/// written in. Content moving between people later will match tags by name
|
||||
/// too.
|
||||
final class const Tag({
|
||||
/// A UUID v7, made on the device that created it.
|
||||
required final String id,
|
||||
|
||||
/// The name, a [canonicalTagName].
|
||||
/// The name, a [tidyTagName].
|
||||
required final String name,
|
||||
|
||||
/// The colour, a `PaletteColor`'s name.
|
||||
|
|
@ -30,8 +31,20 @@ final class const Tag({
|
|||
/// that name (see `Tagging.idsOf`).
|
||||
typedef TagRef = ({String id, String name});
|
||||
|
||||
/// [name] as tags are named: in lowercase, without spaces around it, and with
|
||||
/// one space where it had several. Two names are the same tag when this makes
|
||||
/// them equal.
|
||||
String canonicalTagName(String name) =>
|
||||
name.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();
|
||||
/// [name] as tags are named: without spaces around it, with one space where
|
||||
/// it had several, and in the case it was written in.
|
||||
String tidyTagName(String name) => name.trim().replaceAll(RegExp(r'\s+'), ' ');
|
||||
|
||||
/// [name] as tags are told apart: tidied ([tidyTagName]) and in lowercase.
|
||||
/// Two names are the same tag when this makes them equal, so `Noun` and
|
||||
/// `noun` are one tag.
|
||||
String canonicalTagName(String name) => tidyTagName(name).toLowerCase();
|
||||
|
||||
/// Orders tags by name as people read them: whatever their case, ties by
|
||||
/// case so the order is the same on every device.
|
||||
int compareTagNames(String a, String b) => switch (canonicalTagName(
|
||||
a,
|
||||
).compareTo(canonicalTagName(b))) {
|
||||
0 => a.compareTo(b),
|
||||
final order => order,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,7 +26,9 @@ final class const Tagging(
|
|||
/// there is none.
|
||||
Tag? named(String name) {
|
||||
final canonical = canonicalTagName(name);
|
||||
return all.where((tag) => tag.name == canonical).firstOrNull;
|
||||
return all
|
||||
.where((tag) => canonicalTagName(tag.name) == canonical)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
/// The tags the card [cardId] has, by name.
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ class _TagPickerState() extends State<_TagPicker> {
|
|||
final content = await showTagSheet(
|
||||
context,
|
||||
tagging: tagging,
|
||||
initialName: canonicalTagName(name),
|
||||
initialName: tidyTagName(name),
|
||||
);
|
||||
if (content == null || !mounted) {
|
||||
return;
|
||||
|
|
@ -93,7 +93,7 @@ class _TagPickerState() extends State<_TagPicker> {
|
|||
return;
|
||||
}
|
||||
for (final tag in appeared) {
|
||||
_created.remove(tag.name);
|
||||
_created.remove(canonicalTagName(tag.name));
|
||||
}
|
||||
setState(
|
||||
() => _selected = {..._selected, for (final tag in appeared) tag.id},
|
||||
|
|
@ -143,7 +143,7 @@ class _TagPickerState() extends State<_TagPicker> {
|
|||
final query = canonicalTagName(_query.text);
|
||||
final shown = [
|
||||
for (final tag in tagging.all)
|
||||
if (tag.name.startsWith(query)) tag,
|
||||
if (canonicalTagName(tag.name).startsWith(query)) tag,
|
||||
];
|
||||
final canCreate = query.isNotEmpty && tagging.named(query) == null;
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ class _TagSheetState() extends State<_TagSheet> {
|
|||
final theme = Theme.of(context);
|
||||
final creating = widget.initial == null;
|
||||
final namesake = _namesake;
|
||||
final blank = canonicalTagName(_name.text).isEmpty;
|
||||
final blank = tidyTagName(_name.text).isEmpty;
|
||||
return SheetScaffold(
|
||||
title: creating ? l10n.tagSheetCreateTitle : l10n.tagSheetEditTitle,
|
||||
footer: FilledButton(
|
||||
|
|
@ -86,8 +86,7 @@ class _TagSheetState() extends State<_TagSheet> {
|
|||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
// No keyboard popping up as the sheet opens: the person taps the
|
||||
// field when ready. Names are lowercase, so the keyboard offers no
|
||||
// capitals either.
|
||||
// field when ready.
|
||||
controller: _name,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.tagSheetNameLabel,
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ void main() {
|
|||
Set<String> namesOf(Card card) => {
|
||||
for (final tag in tagging.of(card.id)) tag.name,
|
||||
};
|
||||
expect(namesOf(cat), {'animal', 'jlpt::n5'});
|
||||
expect(namesOf(cat), {'animal', 'JLPT::N5'});
|
||||
expect(namesOf(dog), {'animal'});
|
||||
expect(namesOf(cloze), isEmpty);
|
||||
});
|
||||
|
|
@ -197,9 +197,9 @@ void main() {
|
|||
Set<String> namesOf(Card card) => {
|
||||
for (final tag in tagging.of(card.id)) tag.name,
|
||||
};
|
||||
expect(namesOf(cat), {'animal', 'jlpt::n5'});
|
||||
expect(namesOf(dog), {'animal', 'animals'});
|
||||
expect(namesOf(list), {'animals'});
|
||||
expect(namesOf(cat), {'animal', 'JLPT::N5'});
|
||||
expect(namesOf(dog), {'animal', 'Animals'});
|
||||
expect(namesOf(list), {'Animals'});
|
||||
});
|
||||
|
||||
test('tells how far it got', () async {
|
||||
|
|
|
|||
|
|
@ -147,25 +147,25 @@ void main() {
|
|||
expect(
|
||||
[for (final tag in tagging.of(made.first.id)) tag.name],
|
||||
[
|
||||
'jlpt-n5',
|
||||
'verb',
|
||||
'JLPT-N5',
|
||||
'Verb',
|
||||
],
|
||||
);
|
||||
expect(
|
||||
[for (final tag in tagging.of(made.last.id)) tag.name],
|
||||
[
|
||||
'jlpt-n5',
|
||||
'noun',
|
||||
'JLPT-N5',
|
||||
'Noun',
|
||||
],
|
||||
);
|
||||
expect((await cards.allCards()).every((card) => card.imported), isTrue);
|
||||
});
|
||||
|
||||
test('names the tags by level and word class', () {
|
||||
expect(tagNamesOf(taberu), ['jlpt-n5', 'verb']);
|
||||
expect(tagNamesOf(taberu), ['JLPT-N5', 'Verb']);
|
||||
expect(
|
||||
tagNamesOf(word('1', 'x', pos: ['adj-na', 'n'])),
|
||||
['na-adjective', 'noun'],
|
||||
['na-Adjective', 'Noun'],
|
||||
);
|
||||
expect(tagNamesOf(word('2', 'y', pos: ['vt'])), isEmpty);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -196,7 +196,7 @@ void main() {
|
|||
final card = (await cards.cardsOf('japanese')).single;
|
||||
expect(card.content.source, (pdfId: id, page: 5));
|
||||
expect(card.content.front.plainText, 'は marks the topic');
|
||||
expect((await tags.read()).of(card.id).single.name, 'particles');
|
||||
expect((await tags.read()).of(card.id).single.name, 'Particles');
|
||||
expect(find.text('Page 5 · 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ void main() {
|
|||
bloc.add(
|
||||
const TagsEventCreateRequested((name: 'Verb', colorName: 'fern')),
|
||||
);
|
||||
final created = await showing([('verb', 'fern')]);
|
||||
final created = await showing([('Verb', 'fern')]);
|
||||
bloc.add(
|
||||
TagsEventUpdateRequested(
|
||||
created.tagging.all.single.id,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue