feat(studyset): deleted studysets wait 14 days in Recently deleted
Deleting a studyset blanked it with everything inside it once a four-second Undo was over. It now only marks it (`studyset.deletedAt`, a column as `archivedAt`): it leaves every list, count and session with what is inside it and waits in the library's Recently deleted for 14 days, saying how many are left, with Restore and Delete now. Deleting no longer asks first and offers Undo; Delete now asks. Once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes it for good, blanking first as before; the server can't blank a payload and never writes a person's space (ADR 0007). The connector, publishing and the learner count leave deleted studysets out too. The migration adds one nullable column: app versions before it stop syncing until they update. 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
11a16f5686
commit
4644125cc4
54 changed files with 26969 additions and 313 deletions
|
|
@ -139,11 +139,11 @@ 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 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.
|
||||
- **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: a moment's for cards, notes, paths and PDFs (`DelayedDeletions`), 14 days for a studyset (ADR 0007, decided 2026-10-12, as Vaia keeps them): `StudysetDeletions`, opened with the person's data, marks it deleted (`studyset.deletedAt`), and it waits in the library's Recently deleted with everything inside it, restorable, while every other list, count and session reads without it (`StudysetRepository.all` and `watchAll` leave out what is deleted and inside it, `notDeleted`; `withDeleted` reads everything); once the 14 days are over, whichever of the person's devices looks next (as their data opens, after every sync) deletes the whole subtree for good, what is inside first, each studyset's cards blanked before it, and Delete now does at once after asking. Deleting doesn't ask, offering Undo. Widgets delete, undo and delete for good through it directly (`deleteStudysetWithUndo`, `deleteStudysetForGood`), 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. The server never deletes them (it can't blank a payload; ADR 0007), and its connector, publishing and learner count leave them out too (`notDeleted` in `lib/src/studyset/`). 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, each card on the schedule a session studies it on (`card_study_mode.dart`: written by hand while Write by hand is on, back first while Back first is; `ReviewRepository.schedulesAsStudied`); Home's due forecast still counts front to back, as it reads cards without their content. 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), `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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
52
docs/adr/0007-recently-deleted.md
Normal file
52
docs/adr/0007-recently-deleted.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# ADR 0007: Deleted studysets wait 14 days in Recently deleted
|
||||
|
||||
- **Status:** accepted
|
||||
- **Date:** 2026-10-12
|
||||
|
||||
## Context
|
||||
|
||||
Deleting a studyset blanked and deleted it with everything inside it once a four-second
|
||||
Undo was over (ADR 0002, rule 2: blanking can't be undone, so the Undo comes first). A
|
||||
studyset can hold months of work and thousands of reviews, and a moment's Undo is easy
|
||||
to miss. People expect what they delete to stay restorable for a while, as Vaia keeps
|
||||
deleted studysets for 14 days in a section of their library.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **A deleted studyset is marked, not deleted.** `studyset.deletedAt` records when the
|
||||
person deleted it (null while it isn't), a column like `archivedAt` and for the same
|
||||
reason (ADR 0004): deleting on one device and editing on another must not overwrite
|
||||
each other, which they would inside the payload, whose last writer wins whole. Only
|
||||
the deleted studyset is marked; what is inside it is deleted with it by ancestry, as
|
||||
archiving works.
|
||||
2. **What is deleted counts nowhere.** The app's lists, counts, study sessions, the
|
||||
dashboard, reading deadlines and copies' updates read the studysets without those
|
||||
deleted and what is inside them; so do the server's connector, publishing and the
|
||||
learner count. Restoring brings it all back as it was, progress included.
|
||||
3. **The library's Recently deleted lists them for 14 days** (`Studyset.keptWhenDeleted`),
|
||||
the most recently deleted first, each with the days it has left, Restore, and Delete
|
||||
now. Deleting no longer asks first; it offers Undo, as archiving does. Delete now asks,
|
||||
since it can't be undone. A studyset deleted inside one deleted later is listed only
|
||||
once the outer one is restored, and goes on its own clock.
|
||||
4. **The person's devices delete them for good**, as before, blanking first: whichever
|
||||
device looks next once the 14 days are over (`StudysetDeletions.deleteExpired`, as the
|
||||
person's data opens and after every sync that went through). The server doesn't: it
|
||||
never writes into a person's space (ADR 0005), and the sync engine (checked with
|
||||
v0.0.10) stores a payload the server updates as its JSON text rather than its bytes, so
|
||||
it couldn't blank one. A studyset deleted by someone who never opens the app again
|
||||
therefore stays on the server, marked, like the rest of what they left, until their
|
||||
account goes (pruning, or deleting it).
|
||||
5. **A restore and a deletion for good racing on two devices end deleted**: existence is
|
||||
the sync engine's tombstone, which a field update doesn't lift. That needs one device
|
||||
restoring offline in the last moments of the 14 days while another deletes it, and is
|
||||
accepted.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The migration `20261012162411835-studyset-deleted-at` adds the column. Like every
|
||||
change to a synced table, it locks app versions before it out of syncing until they
|
||||
update (ADR 0002, rule 3); `minimumAppBuild` needn't rise.
|
||||
- Deleted content stays on the server and in backups for up to 14 days longer than
|
||||
before, longer for people who stop using the app. The privacy page should say so.
|
||||
- Deleting a studyset still never withdraws its publication: that stays the Publish
|
||||
sheet's.
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"__className__": "serverpod.DatabaseMigration",
|
||||
"actions": [
|
||||
{
|
||||
"__className__": "serverpod.DatabaseMigrationAction",
|
||||
"type": "alterTable",
|
||||
"alterTable": {
|
||||
"__className__": "serverpod.TableMigration",
|
||||
"name": "studyset",
|
||||
"schema": "public",
|
||||
"addColumns": [
|
||||
{
|
||||
"__className__": "serverpod.ColumnDefinition",
|
||||
"name": "deletedAt",
|
||||
"fieldName": "deletedAt",
|
||||
"columnType": 4,
|
||||
"isNullable": true,
|
||||
"dartType": "DateTime?"
|
||||
}
|
||||
],
|
||||
"deleteColumns": [],
|
||||
"modifyColumns": [],
|
||||
"addIndexes": [],
|
||||
"deleteIndexes": [],
|
||||
"addForeignKeys": [],
|
||||
"deleteForeignKeys": [],
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"warnings": [],
|
||||
"migrationApiVersion": 1
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ part '20261003110855698-pdf-sources/migration.dart';
|
|||
part '20261007205440868-card-buried-at/migration.dart';
|
||||
part '20261008165051407-pdf-position/migration.dart';
|
||||
part '20261009224606274-feedback/migration.dart';
|
||||
part '20261012162411835-studyset-deleted-at/migration.dart';
|
||||
|
||||
/// Migration registry for the client-side database.
|
||||
class MigrationRegistry {
|
||||
|
|
@ -30,6 +31,7 @@ class MigrationRegistry {
|
|||
_Migration20261007205440868_card_buried_at(),
|
||||
_Migration20261008165051407_pdf_position(),
|
||||
_Migration20261009224606274_feedback(),
|
||||
_Migration20261012162411835_studyset_deleted_at(),
|
||||
];
|
||||
|
||||
/// List of all client-side database migration versions.
|
||||
|
|
|
|||
|
|
@ -2075,6 +2075,12 @@ class Protocol extends _isd.DatabaseSerializationManager {
|
|||
isNullable: true,
|
||||
dartType: 'DateTime?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'deletedAt',
|
||||
columnType: _isd.ColumnType.timestampWithoutTimeZone,
|
||||
isNullable: true,
|
||||
dartType: 'DateTime?',
|
||||
),
|
||||
_isd.ColumnDefinition(
|
||||
name: 'starredAt',
|
||||
columnType: _isd.ColumnType.timestampWithoutTimeZone,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ abstract class Studyset
|
|||
this.parent,
|
||||
String? position,
|
||||
this.archivedAt,
|
||||
this.deletedAt,
|
||||
this.starredAt,
|
||||
this.updatedAt,
|
||||
this.keyId,
|
||||
|
|
@ -46,6 +47,7 @@ abstract class Studyset
|
|||
_ie5k1jc2.Studyset? parent,
|
||||
String? position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
String? keyId,
|
||||
|
|
@ -73,6 +75,9 @@ abstract class Studyset
|
|||
: _isc.DateTimeJsonExtension.fromJson(
|
||||
jsonSerialization['archivedAt'],
|
||||
),
|
||||
deletedAt: jsonSerialization['deletedAt'] == null
|
||||
? null
|
||||
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['deletedAt']),
|
||||
starredAt: jsonSerialization['starredAt'] == null
|
||||
? null
|
||||
: _isc.DateTimeJsonExtension.fromJson(jsonSerialization['starredAt']),
|
||||
|
|
@ -118,6 +123,15 @@ abstract class Studyset
|
|||
/// archiving on one device and editing on another don't overwrite each other.
|
||||
DateTime? archivedAt;
|
||||
|
||||
/// When the person deleted it (decided 2026-10-12); null while it isn't. A
|
||||
/// deleted studyset waits in the library's Recently deleted for 14 days,
|
||||
/// together with everything below it, out of every list and of studying, and
|
||||
/// can be restored until then; after that the person's devices delete it for
|
||||
/// good, its content blanked first (ADR 0002, rule 2). A column of its own,
|
||||
/// like archivedAt, so that deleting on one device and editing on another
|
||||
/// don't overwrite each other.
|
||||
DateTime? deletedAt;
|
||||
|
||||
/// When the person starred it, to keep it at the top of the library; null when
|
||||
/// not starred. A column of its own, like archivedAt.
|
||||
DateTime? starredAt;
|
||||
|
|
@ -152,6 +166,7 @@ abstract class Studyset
|
|||
_ie5k1jc2.Studyset? parent,
|
||||
String? position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
String? keyId,
|
||||
|
|
@ -168,6 +183,7 @@ abstract class Studyset
|
|||
if (parent != null) 'parent': parent?.toJson(),
|
||||
'position': position,
|
||||
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
|
||||
if (deletedAt != null) 'deletedAt': deletedAt?.toJson(),
|
||||
if (starredAt != null) 'starredAt': starredAt?.toJson(),
|
||||
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
|
|
@ -186,6 +202,7 @@ abstract class Studyset
|
|||
if (parent != null) 'parent': parent?.toJsonForProtocol(),
|
||||
'position': position,
|
||||
if (archivedAt != null) 'archivedAt': archivedAt?.toJson(),
|
||||
if (deletedAt != null) 'deletedAt': deletedAt?.toJson(),
|
||||
if (starredAt != null) 'starredAt': starredAt?.toJson(),
|
||||
if (updatedAt != null) 'updatedAt': updatedAt?.toJson(),
|
||||
if (keyId != null) 'keyId': keyId,
|
||||
|
|
@ -232,6 +249,7 @@ class _StudysetImpl extends Studyset {
|
|||
_ie5k1jc2.Studyset? parent,
|
||||
String? position,
|
||||
DateTime? archivedAt,
|
||||
DateTime? deletedAt,
|
||||
DateTime? starredAt,
|
||||
DateTime? updatedAt,
|
||||
String? keyId,
|
||||
|
|
@ -244,6 +262,7 @@ class _StudysetImpl extends Studyset {
|
|||
parent: parent,
|
||||
position: position,
|
||||
archivedAt: archivedAt,
|
||||
deletedAt: deletedAt,
|
||||
starredAt: starredAt,
|
||||
updatedAt: updatedAt,
|
||||
keyId: keyId,
|
||||
|
|
@ -262,6 +281,7 @@ class _StudysetImpl extends Studyset {
|
|||
Object? parent = _Undefined,
|
||||
String? position,
|
||||
Object? archivedAt = _Undefined,
|
||||
Object? deletedAt = _Undefined,
|
||||
Object? starredAt = _Undefined,
|
||||
Object? updatedAt = _Undefined,
|
||||
Object? keyId = _Undefined,
|
||||
|
|
@ -275,6 +295,7 @@ class _StudysetImpl extends Studyset {
|
|||
parent: parent is _ie5k1jc2.Studyset? ? parent : this.parent?.copyWith(),
|
||||
position: position ?? this.position,
|
||||
archivedAt: archivedAt is DateTime? ? archivedAt : this.archivedAt,
|
||||
deletedAt: deletedAt is DateTime? ? deletedAt : this.deletedAt,
|
||||
starredAt: starredAt is DateTime? ? starredAt : this.starredAt,
|
||||
updatedAt: updatedAt is DateTime? ? updatedAt : this.updatedAt,
|
||||
keyId: keyId is String? ? keyId : this.keyId,
|
||||
|
|
@ -310,6 +331,12 @@ class StudysetUpdateTable extends _isd.UpdateTable<StudysetTable> {
|
|||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<DateTime, DateTime> deletedAt(DateTime? value) =>
|
||||
_isd.ColumnValue(
|
||||
table.deletedAt,
|
||||
value,
|
||||
);
|
||||
|
||||
_isd.ColumnValue<DateTime, DateTime> starredAt(DateTime? value) =>
|
||||
_isd.ColumnValue(
|
||||
table.starredAt,
|
||||
|
|
@ -360,6 +387,10 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
|
|||
'archivedAt',
|
||||
this,
|
||||
);
|
||||
deletedAt = _isd.ColumnDateTime(
|
||||
'deletedAt',
|
||||
this,
|
||||
);
|
||||
starredAt = _isd.ColumnDateTime(
|
||||
'starredAt',
|
||||
this,
|
||||
|
|
@ -406,6 +437,15 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
|
|||
/// archiving on one device and editing on another don't overwrite each other.
|
||||
late final _isd.ColumnDateTime archivedAt;
|
||||
|
||||
/// When the person deleted it (decided 2026-10-12); null while it isn't. A
|
||||
/// deleted studyset waits in the library's Recently deleted for 14 days,
|
||||
/// together with everything below it, out of every list and of studying, and
|
||||
/// can be restored until then; after that the person's devices delete it for
|
||||
/// good, its content blanked first (ADR 0002, rule 2). A column of its own,
|
||||
/// like archivedAt, so that deleting on one device and editing on another
|
||||
/// don't overwrite each other.
|
||||
late final _isd.ColumnDateTime deletedAt;
|
||||
|
||||
/// When the person starred it, to keep it at the top of the library; null when
|
||||
/// not starred. A column of its own, like archivedAt.
|
||||
late final _isd.ColumnDateTime starredAt;
|
||||
|
|
@ -447,6 +487,7 @@ class StudysetTable extends _isd.Table<_isc.UuidValue?> {
|
|||
parentId,
|
||||
position,
|
||||
archivedAt,
|
||||
deletedAt,
|
||||
starredAt,
|
||||
updatedAt,
|
||||
keyId,
|
||||
|
|
|
|||
|
|
@ -24,10 +24,18 @@
|
|||
"librarySearchNoMatches": "Keine Lernsets für deine Suche gefunden",
|
||||
"libraryStudysetArchived": "Lernset archiviert",
|
||||
"libraryStudysetUnarchived": "Lernset aus dem Archiv geholt",
|
||||
"libraryStudysetDeleted": "Lernset gelöscht",
|
||||
"libraryDeleteTitle": "Lernset löschen?",
|
||||
"libraryDeleteMessage": "Es wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Du kannst dies für einige Sekunden rückgängig machen.",
|
||||
"libraryDeleteConfirm": "Löschen",
|
||||
"libraryStudysetDeleted": "In „Zuletzt gelöscht“ verschoben",
|
||||
"libraryDeletedSection": "Zuletzt gelöscht",
|
||||
"libraryDeletedNote": "Gelöschte Lernsets werden samt allem, was darin ist, 14 Tage aufbewahrt. Danach werden sie endgültig gelöscht.",
|
||||
"libraryDeletedDaysLeft": "{days, plural, =0{Bald endgültig gelöscht} =1{Noch 1 Tag} other{Noch {days} Tage}}",
|
||||
"@libraryDeletedDaysLeft": {"placeholders": {"days": {"type": "int"}}},
|
||||
"libraryRestore": "Wiederherstellen",
|
||||
"libraryDeleteNow": "Jetzt löschen",
|
||||
"libraryStudysetRestored": "Lernset wiederhergestellt",
|
||||
"libraryDeleteForGoodTitle": "Endgültig löschen?",
|
||||
"libraryDeleteForGoodMessage": "„{title}“ wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Das lässt sich nicht rückgängig machen.",
|
||||
"@libraryDeleteForGoodMessage": {"placeholders": {"title": {"type": "String"}}},
|
||||
"libraryDeleteForGoodConfirm": "Endgültig löschen",
|
||||
|
||||
"studysetSheetCreateTitle": "Lernset erstellen",
|
||||
"studysetSheetEditTitle": "Lernset bearbeiten",
|
||||
|
|
|
|||
|
|
@ -25,10 +25,26 @@
|
|||
"librarySearchNoMatches": "No studysets match your search",
|
||||
"libraryStudysetArchived": "Studyset archived",
|
||||
"libraryStudysetUnarchived": "Studyset unarchived",
|
||||
"libraryStudysetDeleted": "Studyset deleted",
|
||||
"libraryDeleteTitle": "Delete studyset?",
|
||||
"libraryDeleteMessage": "It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.",
|
||||
"libraryDeleteConfirm": "Delete",
|
||||
"libraryStudysetDeleted": "Moved to Recently deleted",
|
||||
"libraryDeletedSection": "Recently deleted",
|
||||
"libraryDeletedNote": "Deleted studysets are kept for 14 days, with everything inside them. After that, they are deleted for good.",
|
||||
"libraryDeletedDaysLeft": "{days, plural, =0{Deleted for good soon} =1{1 day left} other{{days} days left}}",
|
||||
"@libraryDeletedDaysLeft": {
|
||||
"placeholders": {
|
||||
"days": {"type": "int"}
|
||||
}
|
||||
},
|
||||
"libraryRestore": "Restore",
|
||||
"libraryDeleteNow": "Delete now",
|
||||
"libraryStudysetRestored": "Studyset restored",
|
||||
"libraryDeleteForGoodTitle": "Delete for good?",
|
||||
"libraryDeleteForGoodMessage": "“{title}” is deleted with everything inside it and all their cards, on all your devices. This can't be undone.",
|
||||
"@libraryDeleteForGoodMessage": {
|
||||
"placeholders": {
|
||||
"title": {"type": "String"}
|
||||
}
|
||||
},
|
||||
"libraryDeleteForGoodConfirm": "Delete for good",
|
||||
|
||||
"studysetSheetCreateTitle": "Create studyset",
|
||||
"studysetSheetEditTitle": "Edit studyset",
|
||||
|
|
|
|||
|
|
@ -223,26 +223,62 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @libraryStudysetDeleted.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studyset deleted'**
|
||||
/// **'Moved to Recently deleted'**
|
||||
String get libraryStudysetDeleted;
|
||||
|
||||
/// No description provided for @libraryDeleteTitle.
|
||||
/// No description provided for @libraryDeletedSection.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete studyset?'**
|
||||
String get libraryDeleteTitle;
|
||||
/// **'Recently deleted'**
|
||||
String get libraryDeletedSection;
|
||||
|
||||
/// No description provided for @libraryDeleteMessage.
|
||||
/// No description provided for @libraryDeletedNote.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.'**
|
||||
String get libraryDeleteMessage;
|
||||
/// **'Deleted studysets are kept for 14 days, with everything inside them. After that, they are deleted for good.'**
|
||||
String get libraryDeletedNote;
|
||||
|
||||
/// No description provided for @libraryDeleteConfirm.
|
||||
/// No description provided for @libraryDeletedDaysLeft.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete'**
|
||||
String get libraryDeleteConfirm;
|
||||
/// **'{days, plural, =0{Deleted for good soon} =1{1 day left} other{{days} days left}}'**
|
||||
String libraryDeletedDaysLeft(int days);
|
||||
|
||||
/// No description provided for @libraryRestore.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Restore'**
|
||||
String get libraryRestore;
|
||||
|
||||
/// No description provided for @libraryDeleteNow.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete now'**
|
||||
String get libraryDeleteNow;
|
||||
|
||||
/// No description provided for @libraryStudysetRestored.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Studyset restored'**
|
||||
String get libraryStudysetRestored;
|
||||
|
||||
/// No description provided for @libraryDeleteForGoodTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete for good?'**
|
||||
String get libraryDeleteForGoodTitle;
|
||||
|
||||
/// No description provided for @libraryDeleteForGoodMessage.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'“{title}” is deleted with everything inside it and all their cards, on all your devices. This can\'t be undone.'**
|
||||
String libraryDeleteForGoodMessage(String title);
|
||||
|
||||
/// No description provided for @libraryDeleteForGoodConfirm.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Delete for good'**
|
||||
String get libraryDeleteForGoodConfirm;
|
||||
|
||||
/// No description provided for @studysetSheetCreateTitle.
|
||||
///
|
||||
|
|
|
|||
|
|
@ -72,17 +72,46 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get libraryStudysetUnarchived => 'Lernset aus dem Archiv geholt';
|
||||
|
||||
@override
|
||||
String get libraryStudysetDeleted => 'Lernset gelöscht';
|
||||
String get libraryStudysetDeleted => 'In „Zuletzt gelöscht“ verschoben';
|
||||
|
||||
@override
|
||||
String get libraryDeleteTitle => 'Lernset löschen?';
|
||||
String get libraryDeletedSection => 'Zuletzt gelöscht';
|
||||
|
||||
@override
|
||||
String get libraryDeleteMessage =>
|
||||
'Es wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Du kannst dies für einige Sekunden rückgängig machen.';
|
||||
String get libraryDeletedNote =>
|
||||
'Gelöschte Lernsets werden samt allem, was darin ist, 14 Tage aufbewahrt. Danach werden sie endgültig gelöscht.';
|
||||
|
||||
@override
|
||||
String get libraryDeleteConfirm => 'Löschen';
|
||||
String libraryDeletedDaysLeft(int days) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
days,
|
||||
locale: localeName,
|
||||
other: 'Noch $days Tage',
|
||||
one: 'Noch 1 Tag',
|
||||
zero: 'Bald endgültig gelöscht',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryRestore => 'Wiederherstellen';
|
||||
|
||||
@override
|
||||
String get libraryDeleteNow => 'Jetzt löschen';
|
||||
|
||||
@override
|
||||
String get libraryStudysetRestored => 'Lernset wiederhergestellt';
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodTitle => 'Endgültig löschen?';
|
||||
|
||||
@override
|
||||
String libraryDeleteForGoodMessage(String title) {
|
||||
return '„$title“ wird samt allem, was darin ist, und allen zugehörigen Karten auf all deinen Geräten gelöscht. Das lässt sich nicht rückgängig machen.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodConfirm => 'Endgültig löschen';
|
||||
|
||||
@override
|
||||
String get studysetSheetCreateTitle => 'Lernset erstellen';
|
||||
|
|
|
|||
|
|
@ -70,17 +70,46 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get libraryStudysetUnarchived => 'Studyset unarchived';
|
||||
|
||||
@override
|
||||
String get libraryStudysetDeleted => 'Studyset deleted';
|
||||
String get libraryStudysetDeleted => 'Moved to Recently deleted';
|
||||
|
||||
@override
|
||||
String get libraryDeleteTitle => 'Delete studyset?';
|
||||
String get libraryDeletedSection => 'Recently deleted';
|
||||
|
||||
@override
|
||||
String get libraryDeleteMessage =>
|
||||
'It is deleted with everything inside it and all their cards, on all your devices. You can undo this for a few seconds.';
|
||||
String get libraryDeletedNote =>
|
||||
'Deleted studysets are kept for 14 days, with everything inside them. After that, they are deleted for good.';
|
||||
|
||||
@override
|
||||
String get libraryDeleteConfirm => 'Delete';
|
||||
String libraryDeletedDaysLeft(int days) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
days,
|
||||
locale: localeName,
|
||||
other: '$days days left',
|
||||
one: '1 day left',
|
||||
zero: 'Deleted for good soon',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryRestore => 'Restore';
|
||||
|
||||
@override
|
||||
String get libraryDeleteNow => 'Delete now';
|
||||
|
||||
@override
|
||||
String get libraryStudysetRestored => 'Studyset restored';
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodTitle => 'Delete for good?';
|
||||
|
||||
@override
|
||||
String libraryDeleteForGoodMessage(String title) {
|
||||
return '“$title” is deleted with everything inside it and all their cards, on all your devices. This can\'t be undone.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get libraryDeleteForGoodConfirm => 'Delete for good';
|
||||
|
||||
@override
|
||||
String get studysetSheetCreateTitle => 'Create studyset';
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ Future<PersonDependencies> _openPerson(
|
|||
notes,
|
||||
paths,
|
||||
pdfSources,
|
||||
);
|
||||
)..deleteExpiredOn(sync.synced);
|
||||
final noteDeletions = NoteDeletions(notes);
|
||||
final cardDeletions = CardDeletions(cards);
|
||||
final pathDeletions = LearningPathDeletions(paths);
|
||||
|
|
@ -411,7 +411,8 @@ Future<PersonDependencies> _openPerson(
|
|||
onDispose: () async {
|
||||
try {
|
||||
// Deletions first: those still waiting are dropped, those under way
|
||||
// finish while their repositories are open.
|
||||
// (a studyset's for good too) finish while their repositories are
|
||||
// open.
|
||||
await deletions.dispose();
|
||||
await noteDeletions.dispose();
|
||||
await cardDeletions.dispose();
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ class PersonDependencies({
|
|||
/// The files of the person's content: images, sounds and PDFs.
|
||||
required final AssetRepository assets,
|
||||
|
||||
/// The studyset deletions that can still be undone.
|
||||
/// The deleted studysets: Recently deleted, and deleting them for good.
|
||||
required final StudysetDeletions deletions,
|
||||
|
||||
/// The note deletions that can still be undone.
|
||||
|
|
|
|||
|
|
@ -65,8 +65,9 @@ class GitHubSignInBloc(
|
|||
case GitHubLinkOutcome.cancelled:
|
||||
emit(const GitHubSignInStateReady());
|
||||
case GitHubLinkOutcome.takenByAnotherPerson:
|
||||
// This device's content is its studysets, for now.
|
||||
if ((await _studysets.all()).isEmpty) {
|
||||
// This device's content is its studysets, for now, those waiting
|
||||
// in Recently deleted too.
|
||||
if ((await _studysets.withDeleted()).isEmpty) {
|
||||
await _switch(emit);
|
||||
} else {
|
||||
emit(const GitHubSignInStateConfirmSwitch());
|
||||
|
|
|
|||
|
|
@ -95,8 +95,27 @@ class HomeBloc(
|
|||
final refresh = ++_refreshes;
|
||||
try {
|
||||
final now = clock.now();
|
||||
final reviews = await _reviews.all();
|
||||
final cards = await _cards.allCards();
|
||||
// What waits in Recently deleted counts nowhere, as if it were gone,
|
||||
// until it is restored.
|
||||
final everything = await _studysets.withDeleted();
|
||||
final kept = {for (final studyset in notDeleted(everything)) studyset.id};
|
||||
final waiting = {
|
||||
for (final studyset in everything)
|
||||
if (!kept.contains(studyset.id)) studyset.id,
|
||||
};
|
||||
final allCards = await _cards.allCards();
|
||||
final cards = [
|
||||
for (final card in allCards)
|
||||
if (!waiting.contains(card.studysetId)) card,
|
||||
];
|
||||
final gone = {
|
||||
for (final card in allCards)
|
||||
if (waiting.contains(card.studysetId)) card.id,
|
||||
};
|
||||
final reviews = [
|
||||
for (final review in await _reviews.all())
|
||||
if (!gone.contains(review.cardId)) review,
|
||||
];
|
||||
final steps = await _pathProgress.doneTimes();
|
||||
final ratings = [
|
||||
for (final review in reviews)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.da
|
|||
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/reading_deadlines.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
|
||||
|
||||
/// The person's reading deadlines today (see [readingDeadlinesOf]), worked
|
||||
/// out anew after every change of their PDFs, their reading or their
|
||||
|
|
@ -33,16 +34,21 @@ class ReadingDeadlinesBloc(
|
|||
}
|
||||
|
||||
Future<ReadingDeadlinesState> _read() async {
|
||||
final studysets = await _studysets.all();
|
||||
final everything = await _studysets.withDeleted();
|
||||
final kept = notDeleted(everything);
|
||||
final keptIds = {for (final studyset in kept) studyset.id};
|
||||
return ReadingDeadlinesState(
|
||||
loaded: true,
|
||||
deadlines: readingDeadlinesOf(
|
||||
await _pdfs.all(),
|
||||
furthest: await _pdfs.furthestPages(),
|
||||
today: calendarDayOf(clock.now()),
|
||||
archived: {
|
||||
for (final Studyset studyset in studysets)
|
||||
if (studyset.isArchived) studyset.id,
|
||||
// Archived with what is inside, or waiting in Recently deleted.
|
||||
resting: {
|
||||
for (final Studyset studyset in everything)
|
||||
if (!keptIds.contains(studyset.id)) studyset.id,
|
||||
for (final Studyset studyset in kept)
|
||||
if (studyset.isArchived) ...subtreeOf(studyset.id, kept),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,18 +13,19 @@ typedef ReadingDeadlines = ({
|
|||
});
|
||||
|
||||
/// The reading deadlines of [pdfs] [today], with [furthest] pages reached
|
||||
/// (by PDF id); met ones and those of [archived] studysets left out.
|
||||
/// (by PDF id); met ones and those of [resting] studysets (archived or
|
||||
/// deleted, or inside one) left out.
|
||||
ReadingDeadlines readingDeadlinesOf(
|
||||
List<PdfSource> pdfs, {
|
||||
required Map<String, int> furthest,
|
||||
required CalendarDay today,
|
||||
required Set<String> archived,
|
||||
required Set<String> resting,
|
||||
}) {
|
||||
final current = <PdfDeadline>[];
|
||||
final upcoming = <PdfDeadline>[];
|
||||
for (final pdf in pdfs) {
|
||||
final deadline = pdf.content.deadline;
|
||||
if (deadline == null || archived.contains(pdf.studysetId)) {
|
||||
if (deadline == null || resting.contains(pdf.studysetId)) {
|
||||
continue;
|
||||
}
|
||||
final status = DeadlineStatus.of(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue