feat(pdf): cards beside a PDF are written in the composer's own editor

The sidebar wrote a front and a back only. It now holds the composer's
editor with a bloc of its own: every kind of card (multiple choice,
vocabulary with Look up, matching, a map, a character), tags, hints and
the explanation, pinning, duplicating and deleting. A new card is tied
to the page shown, which it can be moved to the current one, and waits
for its chapter's tag by name until it is created; text sent from the
pages goes into its front or its answer, as its kind keeps one. Create
saves it once it is complete, as the composer does; going back asks
before discarding what was written.

The screenshot of the pages has a button at the end of the format bar,
outside its scrolling, where an editor's place is for it
(InsertableBlock.inFormatBar); the Insert menu still lists it.

A new card no longer shows twice in the composer when its stored copy
arrives while it is saved.

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-12 23:08:33 +02:00
commit fcee3c2670
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64

View file

@ -146,7 +146,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **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`, `deletedAt`, `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' default order, Custom order, which Reorder changes; PDFs from before it stand at `a0`, in the order they were added) and an append-only `pdf_reading` log (`reached` with a page, `reset`): the furthest page is the furthest reached since the last reset, so devices never conflict. A PDF's file is an asset of up to 50 MB (other files 20 MB): the server takes up to 50 MB and deletes a stored file over 20 MB that doesn't start with `%PDF-`. Deleting a PDF deletes its asset row too unless another PDF has the same file (asset rows are otherwise never deleted). Cards and notes tied to a page keep it in their payload (`source`, `core/content/pdf_page_ref.dart`; card schema 4, note schema 2); a screenshot is an `image` block with `pdf` and `page` attributes.
- **PDFs** (`feature/pdf_source/`): a `pdf_source` row per PDF in a studyset (payload: title, file name, the asset of its file, page count, reading deadline; last writer wins) with its `position` among the studyset's files in a column of its own (decided 2026-10-08; the files' 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. Beside the pages, cards are written in the composer's own editor (`CardDraftEditor`, with a `CardComposerBloc` of the panel's; decided 2026-10-12), every kind with its tags, hints and explanation, tied to the page shown and tagged with its chapter, and saved by Create, as the composer saves a card only once it is complete; the screenshot's button ends the format bar there (`InsertableBlock.inFormatBar`).
- **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. Beside them a Radicals tab lists the 214 Kangxi radicals by their strokes (`feature/kanji/model/radical.dart`: the ideograph each of Unicode's Kangxi radicals stands for, the forms Japanese writes it as inside kanji, Unicode's English name), each listing the kanji KANJIDIC2 files under it (its classical radical, the label `radical:N`, which the curator writes), to add or see; that tab shares the characters' bloc, so what it adds shows as held in a search too. The curator stamps its files with how it converted them (`.conversion`, `conversion` in its `update.dart`): raise it whenever it writes something else from the same download, and instances convert again as soon as they run the new curator rather than up to a week later.
- **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`.