feat(explore): take a later release into a copy, at a tap

A copy's top studyset says when a newer release of what it came from is
out, and what it would bring: changed, new and withdrawn items, counted
against the release the copy last took. Update takes it, never by
itself (ADR 0005, rule 4): cards, notes and steps take the release as a
revision on the one where they last took a release and merge per side,
as a dictionary's corrections do, so what the learner changed stays and
a side both changed is a conflict to settle; studysets, paths and PDFs
take each field the learner left as it was. New items come in where
they belong, unless the learner deleted that part; what the author
withdrew stays, marked, and the card browser filters for it; what the
learner deleted stays deleted; progress is never touched.

The instance gives out any release by its version, for the app to
compare the two. UpdateTaken moves into the shared revision merge, now
that notes and steps take updates too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-09 23:47:41 +02:00
commit 5613530f82
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
44 changed files with 2230 additions and 93 deletions

View file

@ -158,7 +158,7 @@ A spaced-repetition learning app: studysets of flashcards, reviewed on an FSRS s
- **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint. - **The source-code link**: both licences require offering the source of what an instance runs to the people who use it. `instance.info().sourceCodeUrl` names it (the operator's `SOURCE_URL`, else this project's repository) and the app's About dialog shows it next to the licences and every package's notice. Keep it working when changing the dialog or the endpoint.
- **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity. - **Public profiles** (ADR 0005, stage 1 of the public tier): `public_profile` (`serverOnly`, never sent; the app gets a `Profile`, without the person's id) holds a handle (Mastodon's rules: 1 to 30 letters, digits or underscores, unique whatever their case through `handleKey`, a few reserved for the instance; changeable until the first publish, `firstPublishedAt`), a display name, a bio, up to 4 links and an avatar. `ProfileEndpoint` (`identified` only: a profile is for others to see) reads, saves and removes the caller's own; `ProfilesEndpoint` finds anyone's by handle. Handles are on the account domain (`ACCOUNT_DOMAIN`, else the server's public host; `instance.info().accountDomain`), which can never change once anyone follows a handle on it. An avatar is a PNG of at most 1 MB that the app draws anew from the picture chosen (`avatarPng`, so a photo's metadata never leaves the device), kept in the public storage at `avatars/<SHA-256>.png`, named by its content so its URL tells nothing about whose it is, and deleted once no profile shows it. In the app, the Profile tab holds it as a form, for people with an identity.
- **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back. - **Publishing** (ADR 0005, stage 2 of the public tier): `PublishEndpoint` (`identified` only, and a public profile: a release names its author) publishes a studyset with everything inside it as its next release. The server reads the person's synced rows (`readReleaseFiles`, `lib/src/publish/`) and writes them through the `git` command (`ReleaseRepository`, never a library linked in) as a commit on `main` and the annotated tag `v<N>` in a bare repository of the studyset's own, `<REPOSITORIES_DIR>/<public id>.git` (`published_studyset`, `serverOnly`; the public id is random, made at the first publish, never a synced row's id). A release holds a directory per studyset (the published one at the top, each inside it named by its id) with `studyset.json`, `cards/<id>.json`, `notes/<id>.json`, `paths/<id>/path.json` with `steps/<id>.json`, `pdfs/<id>.json`, and the files the content names in `files/<sha256>`, described in `files.json`; each item file holds its id, position, tag names and payload as synced, keys sorted and indented, without what is only its author's (a card's `report`, a PDF's `deadline`), and items being deleted (blank payloads) are left out. Publishing is refused (`PublishRefusedException`) without a profile, unless every studyset of it is under a licence allowing it to be passed on (`release_license.dart`; "no derivatives" allowed, and marked in the app), with a card, note or step of more than one current revision, with files not uploaded yet, when nothing changed, or for a name (`slug`: lower-case letters, digits and hyphens, one per studyset of a person, fixed from the first publish) that won't do. Versions count past both the database's highest and the tags', so a version git has and the database lost is never given again, and a release the database can't record is taken back from git. The first publish fixes the person's handle. Git runs apart from the machine's configuration (no system or user configuration, no prompts) with `gc.auto` off, so objects are never removed. Deleting a person deletes their published studysets with their repositories. In the app, Share in a studyset's action row (a lock, or a globe once published; an icon below medium windows) opens the Publish sheet (`feature/publish/`): its name, whether its licence allows publishing, what changed, its releases. Publishing syncs first, since the server builds the release from what it holds; on a refusal for revisions to settle, the app saves merged those whose changes merge (ADR 0003) and tries once more, and lists those changed differently on one side, each opening where it is settled. An author can take a studyset off Explore again (`withdraw`, `withdrawnAt`): nobody can add it, copies get no updates, its releases stay; publishing it again, changed or not, brings it back.
- **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one (stage 3, next). In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it. - **Explore and copies** (ADR 0005, stage 3, decided 2026-10-09): `ExploreEndpoint` (`requireLogin`: anonymous people explore and learn too) lists the latest release of each published studyset not withdrawn **by fit, never by popularity** (the person's roadmap): search over titles and authors (LIKE's wildcards in what is typed are only themselves), languages (a primary one takes its regional ones), kinds of content held, the newest release first, 30 to a page; the next page only when asked for (Show more, no endless scrolling). What Explore shows of a release is kept beside its repository (`studyset_release`'s title, description, languages, credits), so listing never reads git. `release` gives out the latest release's JSON (one `git cat-file --batch` run) and `file` each file by its hash. **The app makes the copy** (`ReleaseCopier`, `feature/explore/`), as an import: through the person's own repositories, the files among their own files (asset ids in the content swapped for theirs), the tags by name among their own tags, the studysets in their tree, each list in its order; everything is read first, so a release with an item in a schema this app doesn't know is refused whole (`ReleaseUnreadable`). The server never writes into a learner's space. Every item of a copy names where it came from (`published`: publication, item, release; `core/content/published_ref.dart`; card schema 9, note 3, step 3, path 2, PDF 2, studyset 5, the top studyset also with `location`), merged per side like the rest of a card, note or step, so a later release can be taken from where the copy last took one. **A later release is offered, never applied by itself** (`CopyUpdater`, `CopyUpdateBanner` on the copy's top studyset: "v4 is out: 12 changed, 5 new, 2 withdrawn", Update): the app compares it with the release the copy last took (`release(version:)`; releases never change, so the repository caches them) item by item. Cards, notes and steps take the release's content as a revision on the one where they last took a release (`takeRelease`, as `takeDictionaryUpdate`), merging per side: what only the release changed comes in, what the learner changed stays, a side both changed is a conflict settled in the editor. Studysets, paths and PDFs, kept whole, take each field of their payload the learner left as the old release had it; one they changed stays theirs (personal fields, such as a PDF's deadline, too). New items come in where they belong, unless the learner deleted that part; tags the release added come and those it took away go, the learner's own stay; files are fetched only when the person hasn't the same file already (`AssetRepository.idOf`). What the author withdrew stays the learner's, studied on, marked (`withdrawn` in its origin; the card browser's filter Withdrawn by the author); what the learner deleted stays deleted; progress is never touched. A copy of a studyset its author withdrew is offered nothing. The server never updates synced rows itself: the sync engine (v0.0.10) stores a ByteData the server updates through it as its JSON text, not its bytes (`_updateRowWithoutRecording`), unreported upstream so far. In the app, Explore is the old app's feed: cards with title, author, the start of the description, the latest release, what it holds, the licence and Add to library (its progress, then In your library, opening the copy); a summary sheet with all of it.
- **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again. - **Deleting a person** goes through `deletePerson` (`lib/src/auth/account.dart`), in one transaction and in ADR 0001 rule 6's order: their sessions end first (a device with a valid session would re-upload everything), then their space goes with all its content through the foreign keys, then the person with their identities. `AccountEndpoint.delete` deletes the caller; the app also calls it when an anonymous account is given up (signed out of, or switched away from to GitHub), since nobody could ever sign into it again.
- **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first. - **Pruning**: `PruningFutureCall` deletes anonymous people unused for 90 days, daily, scheduled when the server starts. Scheduling a future call always adds an entry, so the server cancels the previous one by its identifier first.
- **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close. - **One person's data per device**: `PersonFolders` wipes every other person's folder when a person's data opens: their sessions ended on this device. Opening waits for the previous person's database to close.

View file

@ -366,15 +366,21 @@ class EndpointExplore extends _isc.EndpointRef {
{'publicIds': publicIds}, {'publicIds': publicIds},
); );
/// The latest release of the published studyset [publicId], to make a /// Release [version] of the published studyset [publicId], else its
/// copy of or take into one. Throws [NotFoundException] for a studyset /// latest: to make a copy of, or to take into one from the release it
/// withdrawn or never published. /// last took. Throws [NotFoundException] for a release that doesn't
_ida.Future<_icu7niot.ReleaseContent> release(_isc.UuidValue publicId) => /// exist, or a studyset withdrawn or never published.
caller.callServerEndpoint<_icu7niot.ReleaseContent>( _ida.Future<_icu7niot.ReleaseContent> release(
'explore', _isc.UuidValue publicId, {
'release', int? version,
{'publicId': publicId}, }) => caller.callServerEndpoint<_icu7niot.ReleaseContent>(
); 'explore',
'release',
{
'publicId': publicId,
'version': version,
},
);
/// The file [sha256] of release [version] of the published studyset /// The file [sha256] of release [version] of the published studyset
/// [publicId]. Throws [NotFoundException] for a file the release doesn't /// [publicId]. Throws [NotFoundException] for a file the release doesn't

View file

@ -684,7 +684,7 @@
"composerDiscardTitle": "{count, plural, =1{Nicht gespeicherte Karteikarte verwerfen?} other{Nicht gespeicherte Karteikarten verwerfen?}}", "composerDiscardTitle": "{count, plural, =1{Nicht gespeicherte Karteikarte verwerfen?} other{Nicht gespeicherte Karteikarten verwerfen?}}",
"composerDiscardMessage": "{count, plural, =1{1 Karteikarte wurde noch nicht gespeichert. Wenn du jetzt gehst, wird sie verworfen.} other{{count} Karteikarten wurden noch nicht gespeichert. Wenn du jetzt gehst, werden sie verworfen.}}", "composerDiscardMessage": "{count, plural, =1{1 Karteikarte wurde noch nicht gespeichert. Wenn du jetzt gehst, wird sie verworfen.} other{{count} Karteikarten wurden noch nicht gespeichert. Wenn du jetzt gehst, werden sie verworfen.}}",
"composerDiscardConfirm": "Verwerfen", "composerDiscardConfirm": "Verwerfen",
"composerConflict": "Diese Karteikarte wurde auf zwei Arten zugleich geändert: auf zwei deiner Geräte oder von dir und durch eine Korrektur im Wörterbuch. Wähle unten für jeden Teil die Version, die du behalten möchtest.", "composerConflict": "Diese Karteikarte wurde auf zwei Arten zugleich geändert: auf zwei deiner Geräte oder von dir und durch eine Korrektur im Wörterbuch oder eine neue Veröffentlichung dessen, wovon es kopiert ist. Wähle unten für jeden Teil die Version, die du behalten möchtest.",
"composerKeepThis": "Diese behalten", "composerKeepThis": "Diese behalten",
"formatAlignLeft": "Linksbündig", "formatAlignLeft": "Linksbündig",
"formatAlignCenter": "Zentriert", "formatAlignCenter": "Zentriert",
@ -1431,5 +1431,15 @@
"publishWithdrawTitle": "Aus Entdecken nehmen?", "publishWithdrawTitle": "Aus Entdecken nehmen?",
"publishWithdrawMessage": "Niemand kann es dann noch hinzufügen, und Kopien bekommen keine Aktualisierungen. Seine Veröffentlichungen bleiben, wie sie sind; veröffentlichst du es wieder, ist es zurück.", "publishWithdrawMessage": "Niemand kann es dann noch hinzufügen, und Kopien bekommen keine Aktualisierungen. Seine Veröffentlichungen bleiben, wie sie sind; veröffentlichst du es wieder, ist es zurück.",
"publishWithdrawn": "Zurückgezogen · Kopien bekommen keine Aktualisierungen", "publishWithdrawn": "Zurückgezogen · Kopien bekommen keine Aktualisierungen",
"publishWithdrawnDone": "Aus Entdecken genommen." "publishWithdrawnDone": "Aus Entdecken genommen.",
"copyUpdateOut": "v{version} ist da: {summary}.",
"copyUpdateChanged": "{count} geändert",
"copyUpdateAdded": "{count} neu",
"copyUpdateWithdrawn": "{count} zurückgezogen",
"copyUpdateNothing": "an deiner Kopie ändert sich nichts",
"copyUpdate": "Aktualisieren",
"copyUpdated": "Auf v{version} aktualisiert.",
"copyUpdatedToSettle": "{count, plural, =1{Bei einem hast du dort auch etwas geändert: öffne es, um zu wählen.} other{Bei {count} hast du dort auch etwas geändert: öffne sie, um zu wählen.}}",
"copyUpdateFailed": "Deine Instanz war nicht erreichbar. Versuche es noch einmal.",
"cardBrowserStatusWithdrawn": "Im Original zurückgezogen"
} }

View file

@ -746,7 +746,7 @@
"composerDiscardMessage": "{count, plural, =1{1 flashcard hasn't been saved yet. Leaving now will discard it.} other{{count} flashcards haven't been saved yet. Leaving now will discard them.}}", "composerDiscardMessage": "{count, plural, =1{1 flashcard hasn't been saved yet. Leaving now will discard it.} other{{count} flashcards haven't been saved yet. Leaving now will discard them.}}",
"@composerDiscardMessage": {"placeholders": {"count": {"type": "int"}}}, "@composerDiscardMessage": {"placeholders": {"count": {"type": "int"}}},
"composerDiscardConfirm": "Discard", "composerDiscardConfirm": "Discard",
"composerConflict": "This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary. Choose the version to keep of each part below.", "composerConflict": "This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary or a new release of what it was copied from. Choose the version to keep of each part below.",
"composerKeepThis": "Keep this", "composerKeepThis": "Keep this",
"formatAlignLeft": "Align left", "formatAlignLeft": "Align left",
"formatAlignCenter": "Centre", "formatAlignCenter": "Centre",
@ -1670,5 +1670,21 @@
"publishWithdrawTitle": "Take it off Explore?", "publishWithdrawTitle": "Take it off Explore?",
"publishWithdrawMessage": "Nobody can add it any more, and copies get no updates. Its releases stay as they are; publishing it again brings it back.", "publishWithdrawMessage": "Nobody can add it any more, and copies get no updates. Its releases stay as they are; publishing it again brings it back.",
"publishWithdrawn": "Withdrawn · copies get no updates", "publishWithdrawn": "Withdrawn · copies get no updates",
"publishWithdrawnDone": "Taken off Explore." "publishWithdrawnDone": "Taken off Explore.",
"copyUpdateOut": "v{version} is out: {summary}.",
"@copyUpdateOut": {"placeholders": {"version": {"type": "int"}, "summary": {"type": "String"}}},
"copyUpdateChanged": "{count} changed",
"@copyUpdateChanged": {"placeholders": {"count": {"type": "int"}}},
"copyUpdateAdded": "{count} new",
"@copyUpdateAdded": {"placeholders": {"count": {"type": "int"}}},
"copyUpdateWithdrawn": "{count} withdrawn",
"@copyUpdateWithdrawn": {"placeholders": {"count": {"type": "int"}}},
"copyUpdateNothing": "nothing in your copy changes",
"copyUpdate": "Update",
"copyUpdated": "Updated to v{version}.",
"@copyUpdated": {"placeholders": {"version": {"type": "int"}}},
"copyUpdatedToSettle": "{count, plural, =1{One has a change of yours there too: open it to choose.} other{{count} have a change of yours there too: open them to choose.}}",
"@copyUpdatedToSettle": {"placeholders": {"count": {"type": "int"}}},
"copyUpdateFailed": "Couldn't reach your instance. Try again.",
"cardBrowserStatusWithdrawn": "Withdrawn by the author"
} }

View file

@ -4051,7 +4051,7 @@ abstract class AppLocalizations {
/// No description provided for @composerConflict. /// No description provided for @composerConflict.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary. Choose the version to keep of each part below.'** /// **'This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary or a new release of what it was copied from. Choose the version to keep of each part below.'**
String get composerConflict; String get composerConflict;
/// No description provided for @composerKeepThis. /// No description provided for @composerKeepThis.
@ -8498,6 +8498,66 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Taken off Explore.'** /// **'Taken off Explore.'**
String get publishWithdrawnDone; String get publishWithdrawnDone;
/// No description provided for @copyUpdateOut.
///
/// In en, this message translates to:
/// **'v{version} is out: {summary}.'**
String copyUpdateOut(int version, String summary);
/// No description provided for @copyUpdateChanged.
///
/// In en, this message translates to:
/// **'{count} changed'**
String copyUpdateChanged(int count);
/// No description provided for @copyUpdateAdded.
///
/// In en, this message translates to:
/// **'{count} new'**
String copyUpdateAdded(int count);
/// No description provided for @copyUpdateWithdrawn.
///
/// In en, this message translates to:
/// **'{count} withdrawn'**
String copyUpdateWithdrawn(int count);
/// No description provided for @copyUpdateNothing.
///
/// In en, this message translates to:
/// **'nothing in your copy changes'**
String get copyUpdateNothing;
/// No description provided for @copyUpdate.
///
/// In en, this message translates to:
/// **'Update'**
String get copyUpdate;
/// No description provided for @copyUpdated.
///
/// In en, this message translates to:
/// **'Updated to v{version}.'**
String copyUpdated(int version);
/// No description provided for @copyUpdatedToSettle.
///
/// In en, this message translates to:
/// **'{count, plural, =1{One has a change of yours there too: open it to choose.} other{{count} have a change of yours there too: open them to choose.}}'**
String copyUpdatedToSettle(int count);
/// No description provided for @copyUpdateFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t reach your instance. Try again.'**
String get copyUpdateFailed;
/// No description provided for @cardBrowserStatusWithdrawn.
///
/// In en, this message translates to:
/// **'Withdrawn by the author'**
String get cardBrowserStatusWithdrawn;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2459,7 +2459,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get composerConflict => String get composerConflict =>
'Diese Karteikarte wurde auf zwei Arten zugleich geändert: auf zwei deiner Geräte oder von dir und durch eine Korrektur im Wörterbuch. Wähle unten für jeden Teil die Version, die du behalten möchtest.'; 'Diese Karteikarte wurde auf zwei Arten zugleich geändert: auf zwei deiner Geräte oder von dir und durch eine Korrektur im Wörterbuch oder eine neue Veröffentlichung dessen, wovon es kopiert ist. Wähle unten für jeden Teil die Version, die du behalten möchtest.';
@override @override
String get composerKeepThis => 'Diese behalten'; String get composerKeepThis => 'Diese behalten';
@ -5315,4 +5315,55 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get publishWithdrawnDone => 'Aus Entdecken genommen.'; String get publishWithdrawnDone => 'Aus Entdecken genommen.';
@override
String copyUpdateOut(int version, String summary) {
return 'v$version ist da: $summary.';
}
@override
String copyUpdateChanged(int count) {
return '$count geändert';
}
@override
String copyUpdateAdded(int count) {
return '$count neu';
}
@override
String copyUpdateWithdrawn(int count) {
return '$count zurückgezogen';
}
@override
String get copyUpdateNothing => 'an deiner Kopie ändert sich nichts';
@override
String get copyUpdate => 'Aktualisieren';
@override
String copyUpdated(int version) {
return 'Auf v$version aktualisiert.';
}
@override
String copyUpdatedToSettle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other:
'Bei $count hast du dort auch etwas geändert: öffne sie, um zu wählen.',
one:
'Bei einem hast du dort auch etwas geändert: öffne es, um zu wählen.',
);
return '$_temp0';
}
@override
String get copyUpdateFailed =>
'Deine Instanz war nicht erreichbar. Versuche es noch einmal.';
@override
String get cardBrowserStatusWithdrawn => 'Im Original zurückgezogen';
} }

View file

@ -2442,7 +2442,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get composerConflict => String get composerConflict =>
'This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary. Choose the version to keep of each part below.'; 'This flashcard was changed in two ways at once: on two of your devices, or by you and by a correction from the dictionary or a new release of what it was copied from. Choose the version to keep of each part below.';
@override @override
String get composerKeepThis => 'Keep this'; String get composerKeepThis => 'Keep this';
@ -5270,6 +5270,54 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get publishWithdrawnDone => 'Taken off Explore.'; String get publishWithdrawnDone => 'Taken off Explore.';
@override
String copyUpdateOut(int version, String summary) {
return 'v$version is out: $summary.';
}
@override
String copyUpdateChanged(int count) {
return '$count changed';
}
@override
String copyUpdateAdded(int count) {
return '$count new';
}
@override
String copyUpdateWithdrawn(int count) {
return '$count withdrawn';
}
@override
String get copyUpdateNothing => 'nothing in your copy changes';
@override
String get copyUpdate => 'Update';
@override
String copyUpdated(int version) {
return 'Updated to v$version.';
}
@override
String copyUpdatedToSettle(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: '$count have a change of yours there too: open them to choose.',
one: 'One has a change of yours there too: open it to choose.',
);
return '$_temp0';
}
@override
String get copyUpdateFailed => 'Couldn\'t reach your instance. Try again.';
@override
String get cardBrowserStatusWithdrawn => 'Withdrawn by the author';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -85,6 +85,11 @@ class AssetRepository(
}); });
} }
/// The id of the asset row of the file with [sha256]; null while the
/// person has none.
Future<String?> idOf(String sha256) async =>
(await _rows.bySha256(sha256))?.id;
/// The file of the asset row [id]: from this device, else downloaded. /// The file of the asset row [id]: from this device, else downloaded.
/// Null when it can't be had now: no such row, no connection, or not /// Null when it can't be had now: no such row, no connection, or not
/// uploaded yet. /// uploaded yet.

View file

@ -1,15 +1,40 @@
/// Where an item of a copy came from (ADR 0005, rule 4): the published import 'package:equatable/equatable.dart';
/// studyset (`publication`, its public id), the item there (its card's,
/// note's, step's, path's, PDF's or studyset's id in the release), and the
/// release whose content the item last took (`version`). A later release
/// is taken from that one on, so what the learner changed since stays.
typedef PublishedRef = ({String publication, String item, int version});
/// [ref] as content payloads store it: `{"publication", "item", "version"}`. /// Where an item of a copy came from (ADR 0005, rule 4): the published
/// studyset ([publication], its public id), the item there (its card's,
/// note's, step's, path's, PDF's or studyset's id in the release), and the
/// release whose content the item last took ([version]). A later release
/// is taken from that one on, so what the learner changed since stays. An
/// item the author withdrew in [version] stays the learner's, [withdrawn].
final class const PublishedRef({
required final String publication,
required final String item,
required final int version,
final bool withdrawn = false,
}) with Equatable {
/// This origin as of release [version], whose content the item took.
PublishedRef taking(int version) =>
PublishedRef(publication: publication, item: item, version: version);
/// This origin with the item withdrawn in release [version].
PublishedRef withdrawnIn(int version) => PublishedRef(
publication: publication,
item: item,
version: version,
withdrawn: true,
);
@override
List<Object?> get props => [publication, item, version, withdrawn];
}
/// [ref] as content payloads store it: `{"publication", "item", "version"}`,
/// and `"withdrawn": true` for an item the author withdrew.
Map<String, Object?> publishedRefToJson(PublishedRef ref) => { Map<String, Object?> publishedRefToJson(PublishedRef ref) => {
'publication': ref.publication, 'publication': ref.publication,
'item': ref.item, 'item': ref.item,
'version': ref.version, 'version': ref.version,
if (ref.withdrawn) 'withdrawn': true,
}; };
/// The origin [json] names, as [publishedRefToJson] wrote it; null for null. /// The origin [json] names, as [publishedRefToJson] wrote it; null for null.
@ -22,6 +47,11 @@ PublishedRef? publishedRefFromJson(Object? json) => switch (json) {
'version': final int version, 'version': final int version,
} }
when version > 0 => when version > 0 =>
(publication: publication, item: item, version: version), PublishedRef(
publication: publication,
item: item,
version: version,
withdrawn: json['withdrawn'] == true,
),
_ => throw const FormatException('A malformed published origin.'), _ => throw const FormatException('A malformed published origin.'),
}; };

View file

@ -144,3 +144,18 @@ bool _same(Object? a, Object? b) => switch ((a, b)) {
[for (var i = 0; i < a.length; i++) a[i] == b[i]].every((same) => same), [for (var i = 0; i < a.length; i++) a[i] == b[i]].every((same) => same),
_ => a == b, _ => a == b,
}; };
/// What taking an update into content kept as revisions came to: a
/// dictionary's correction into a card, a release into a copy's card, note
/// or step.
enum UpdateTaken() {
/// Nothing: the content can't take it.
none,
/// Taken whole.
taken,
/// Taken, but the person changed a side the update changed too: a
/// conflict to settle.
toSettle,
}

View file

@ -29,6 +29,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/sync_card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/serverpod_dictionary_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/serverpod_dictionary_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
@ -310,6 +311,7 @@ Future<PersonDependencies> _openPerson(
explore: ExploreRepository( explore: ExploreRepository(
ServerpodExploreGateway(client), ServerpodExploreGateway(client),
ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets), ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets),
CopyUpdater(studysets, cards, notes, paths, pdfSources, tags, assets),
studysets, studysets,
), ),
onDispose: () async { onDispose: () async {

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:fractional_indexing_dart/fractional_indexing_dart.dart'; import 'package:fractional_indexing_dart/fractional_indexing_dart.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart'; import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
@ -20,19 +21,6 @@ typedef CardStamp = ({
bool imported, bool imported,
}); });
/// What taking a dictionary's correction into a card came to.
enum UpdateTaken() {
/// Nothing: the card can't take it.
none,
/// Taken whole.
taken,
/// Taken, but the person changed a side the correction changed too: a
/// conflict to settle.
toSettle,
}
/// The person's flashcards: content kept as append-only revisions (ADR 0001, /// The person's flashcards: content kept as append-only revisions (ADR 0001,
/// rule 2), merged per side when devices edited offline, ordered by a /// rule 2), merged per side when devices edited offline, ordered by a
/// fractional index (decided 2026-09-30). /// fractional index (decided 2026-09-30).
@ -246,23 +234,51 @@ class CardRepository(final CardGateway _gateway) {
Future<UpdateTaken> takeDictionaryUpdate( Future<UpdateTaken> takeDictionaryUpdate(
String cardId, String cardId,
CardContent Function(CardContent base) update, CardContent Function(CardContent base) update,
) => _takeOnto(
cardId,
(content) => switch (content) {
CardContent(entry: final entry?, entryVersion: final version?) => (
entry,
version,
),
_ => null,
},
update,
);
/// Writes what [update] makes of the card [cardId] as it was when it last
/// took a release of the published studyset it was copied from (ADR 0005,
/// rule 4): the first of its revisions with the origin it has now. As
/// with [takeDictionaryUpdate], what the person changed since stays, and
/// a side both changed is a conflict to settle. [UpdateTaken.none],
/// writing nothing, for a card of the person's own.
Future<UpdateTaken> takeRelease(
String cardId,
CardContent Function(CardContent base) update,
) => _takeOnto(cardId, (content) => content.published, update);
/// Writes what [update] makes of the card [cardId] as it was in the first
/// of its revisions whose [keyOf] is the one its content has now, as a
/// revision beside whatever came after; [UpdateTaken.none] when its
/// content has none.
Future<UpdateTaken> _takeOnto(
String cardId,
Object? Function(CardContent content) keyOf,
CardContent Function(CardContent base) update,
) async { ) async {
// Revision ids sort by when they were written. // Revision ids sort by when they were written.
final revisions = [ final revisions = [
for (final row in await _gateway.revisionsOf({cardId})) ?_decode(row), for (final row in await _gateway.revisionsOf({cardId})) ?_decode(row),
]..sort((a, b) => a.id.compareTo(b.id)); ]..sort((a, b) => a.id.compareTo(b.id));
final now = mergeRevisions(revisions)?.content; final key = switch (mergeRevisions(revisions)) {
final entry = now?.entry; final merged? => keyOf(merged.content),
final version = now?.entryVersion; null => null,
if (entry == null || version == null) { };
if (key == null) {
return UpdateTaken.none; return UpdateTaken.none;
} }
final base = revisions final base = revisions
.where( .where((revision) => keyOf(revision.content) == key)
(revision) =>
revision.content.entry == entry &&
revision.content.entryVersion == version,
)
.firstOrNull; .firstOrNull;
if (base == null) { if (base == null) {
return UpdateTaken.none; return UpdateTaken.none;

View file

@ -38,6 +38,10 @@ enum CardStatus() {
/// Reported as wrong, not resolved yet. /// Reported as wrong, not resolved yet.
reported, reported,
/// In a copy of a published studyset, withdrawn by its author in a later
/// release: still the learner's, theirs to keep or delete.
withdrawn,
} }
/// What kind of card the browser shows. /// What kind of card the browser shows.
@ -155,6 +159,7 @@ bool isInState(
}, },
CardStatus.buried => card.card.isBuried(startOfToday), CardStatus.buried => card.card.isBuried(startOfToday),
CardStatus.reported => card.card.content.report != null, CardStatus.reported => card.card.content.report != null,
CardStatus.withdrawn => card.card.content.published?.withdrawn ?? false,
}; };
} }

View file

@ -121,9 +121,17 @@ class _FilterSheetState() extends State<_FilterSheet> {
children: [ children: [
for (final status in CardStatus.values) for (final status in CardStatus.values)
FilterChip( FilterChip(
avatar: status == CardStatus.reported avatar: switch (status) {
? const Icon(LucideIcons.flag, size: 16) CardStatus.reported => const Icon(
: null, LucideIcons.flag,
size: 16,
),
CardStatus.withdrawn => const Icon(
LucideIcons.archiveX,
size: 16,
),
_ => null,
},
label: Text(_statusName(l10n, status)), label: Text(_statusName(l10n, status)),
selected: _states.contains(status), selected: _states.contains(status),
onSelected: (on) => setState( onSelected: (on) => setState(
@ -156,6 +164,7 @@ String _statusName(AppLocalizations l10n, CardStatus status) =>
CardStatus.due => l10n.cardBrowserStatusDue, CardStatus.due => l10n.cardBrowserStatusDue,
CardStatus.buried => l10n.cardBrowserStatusBuried, CardStatus.buried => l10n.cardBrowserStatusBuried,
CardStatus.reported => l10n.cardBrowserStatusReported, CardStatus.reported => l10n.cardBrowserStatusReported,
CardStatus.withdrawn => l10n.cardBrowserStatusWithdrawn,
}; };
/// Asks which tags to give [count] chosen cards, among all the person's; /// Asks which tags to give [count] chosen cards, among all the person's;

View file

@ -1,6 +1,7 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart'; import 'package:halkyon_learn_flutter/src/core/editor/rich_content_view.dart';
import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart'; import 'package:halkyon_learn_flutter/src/feature/card/bloc/card_composer_bloc.dart';
@ -788,7 +789,7 @@ class const _Version({required final Object? version}) extends StatelessWidget {
AppLocalizations.of(context).pdfPageShort(page), AppLocalizations.of(context).pdfPageShort(page),
), ),
(source: final String source, id: final String id) => Text('$source $id'), (source: final String source, id: final String id) => Text('$source $id'),
(publication: _, item: _, version: final int version) => Text( PublishedRef(:final version) => Text(
AppLocalizations.of(context).publishedVersion(version), AppLocalizations.of(context).publishedVersion(version),
), ),
_ => Text('${version ?? ''}'), _ => Text('${version ?? ''}'),

View file

@ -2,7 +2,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_updates.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_updates.dart';

View file

@ -1,3 +1,4 @@
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart'; import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';

View file

@ -0,0 +1,94 @@
import 'dart:developer';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_repository.dart';
/// Whether a newer release of the published studyset the copy [_rootId]
/// came from is out, and taking it, never by itself (ADR 0005, rule 4).
class CopyUpdatesBloc(
final ExploreRepository _explore, {
required final String _rootId,
}) extends Bloc<CopyUpdatesEvent, CopyUpdatesState> {
this : super(const CopyUpdatesState()) {
on<CopyUpdatesEventChecked>(_onChecked);
on<CopyUpdatesEventTaken>(_onTaken);
}
Future<void> _onChecked(
CopyUpdatesEventChecked event,
Emitter<CopyUpdatesState> emit,
) async {
try {
emit(CopyUpdatesState(update: await _explore.updateOf(_rootId)));
} on Object catch (error) {
// Offered when the instance answers; nothing to say otherwise.
log('Checking for a newer release failed', name: 'Explore', error: error);
}
}
Future<void> _onTaken(
CopyUpdatesEventTaken event,
Emitter<CopyUpdatesState> emit,
) async {
final update = state.update;
if (update == null || state.taking) {
return;
}
emit(CopyUpdatesState(update: update, taking: true));
try {
final toSettle = await _explore.takeUpdate(_rootId, update.version);
emit(
CopyUpdatesState(
outcome: CopyUpdated(version: update.version, toSettle: toSettle),
),
);
} on Object catch (error) {
log('Taking a newer release failed', name: 'Explore', error: error);
emit(CopyUpdatesState(update: update, outcome: const CopyUpdateFailed()));
}
}
}
/// What taking a release came to, said once.
sealed class const CopyUpdatesOutcome() with Equatable {
@override
List<Object?> get props => const [];
}
/// The copy holds release [version] now; [toSettle] cards, notes and steps
/// have a conflict to settle.
final class const CopyUpdated({
required final int version,
final int toSettle = 0,
}) extends CopyUpdatesOutcome {
@override
List<Object?> get props => [version, toSettle];
}
/// The instance couldn't be reached; nothing changed.
final class const CopyUpdateFailed() extends CopyUpdatesOutcome;
/// A newer release to offer ([update], null for none), whether it is being
/// taken, and what the last try came to ([outcome]).
final class const CopyUpdatesState({
final CopyUpdate? update,
final bool taking = false,
final CopyUpdatesOutcome? outcome,
}) with Equatable {
@override
List<Object?> get props => [update, taking, outcome];
}
/// What happens to the copy's updates.
sealed class const CopyUpdatesEvent() with Equatable {
@override
List<Object?> get props => const [];
}
/// The copy shows: ask whether a newer release is out.
final class const CopyUpdatesEventChecked() extends CopyUpdatesEvent;
/// The person takes the newer release.
final class const CopyUpdatesEventTaken() extends CopyUpdatesEvent;

View file

@ -0,0 +1,700 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:equatable/equatable.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/core/content/revision_merge.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_entries.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path_step.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note_content.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.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_content.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_tree.dart';
import 'package:halkyon_learn_flutter/src/feature/tag/data/tag_repository.dart';
/// What a later release would bring a copy, counted from the release it
/// last took: items it changed, items new in it, items its author withdrew
/// (of those the copy still holds).
final class const CopyUpdate({
required final int version,
final int changed = 0,
final int added = 0,
final int withdrawn = 0,
}) with Equatable {
/// Whether it brings nothing.
bool get isEmpty => changed == 0 && added == 0 && withdrawn == 0;
@override
List<Object?> get props => [version, changed, added, withdrawn];
}
/// Takes a later release of a published studyset into the person's copy of
/// it (ADR 0005, rule 4), from the release the copy last took, never by
/// itself:
///
/// - what only the release changed comes in, what the learner changed
/// stays: cards, notes and steps take the release's content as a revision
/// beside the one where they last took one, merging per side, so a side
/// both changed is a conflict the learner settles (as a dictionary's
/// corrections, ADR 0003); studysets, paths and PDFs, kept whole, take
/// each field the learner left as it was;
/// - what is new in it comes in where it belongs, unless the learner deleted
/// that part of the copy;
/// - what the author withdrew stays, marked (`withdrawn`), studied on;
/// - what the learner deleted stays deleted, and progress is never touched.
class CopyUpdater(
final StudysetRepository _studysets,
final CardRepository _cards,
final NoteRepository _notes,
final LearningPathRepository _paths,
final PdfSourceRepository _pdfs,
final TagRepository _tags,
final AssetRepository _assets,
) {
/// What taking [newer] into the copy whose top studyset is [rootId] would
/// bring, from [base], the release the copy last took.
Future<CopyUpdate> plan(
String rootId,
PublishedRelease base,
PublishedRelease newer,
) async {
final diff = _Diff(
await _copyOf(rootId, newer.publicId),
releaseEntries(base),
releaseEntries(newer),
);
return CopyUpdate(
version: newer.version,
changed: diff.changed.length,
added: diff.added.length,
withdrawn: diff.withdrawn.length,
);
}
/// Takes [newer] into the copy whose top studyset is [rootId], from
/// [base], the release the copy last took; the files its content names
/// read through [fileOf] unless the person has them already. Returns how
/// many cards, notes and steps have a conflict to settle now.
Future<int> take(
String rootId,
PublishedRelease base,
PublishedRelease newer, {
required Future<Uint8List> Function(String sha256) fileOf,
}) async {
final copy = await _copyOf(rootId, newer.publicId);
final diff = _Diff(copy, releaseEntries(base), releaseEntries(newer));
final version = newer.version;
// The author's files as the person's own: those of the base to read the
// base as the copy holds it, those of the newer release to write it.
final own = <String, String>{};
for (final release in [base, newer]) {
final files = switch (release.items['files.json']) {
final Map<String, Object?> index => index,
null => const <String, Object?>{},
};
for (final MapEntry(key: id, value: file) in files.entries) {
if (file case {
'sha256': final String sha256,
'mimeType': final String mimeType,
}) {
final mine =
await _assets.idOf(sha256) ??
(identical(release, newer)
? await _assets.add(
await fileOf(sha256),
fileName: switch (file['payload']) {
{'fileName': final String name} when name.isNotEmpty =>
name,
_ => sha256,
},
mimeType: mimeType,
)
: null);
if (mine != null) {
own[id] = mine;
}
}
}
}
Map<String, Object?> owned(Map<String, Object?> payload) {
var text = jsonEncode(payload);
for (final MapEntry(key: author, value: person) in own.entries) {
text = text.replaceAll(author, person);
}
return jsonDecode(text) as Map<String, Object?>;
}
var toSettle = 0;
void count(UpdateTaken taken) {
if (taken == UpdateTaken.toSettle) {
toSettle++;
}
}
final tagging = await _tags.read();
final tagIds = <String, String>{};
Future<Set<String>> tagIdsOf(Iterable<String> names) async => {
for (final name in names)
tagIds[name.toLowerCase()] ??= await _tags.create(name),
};
// The item's tags with those the release added and without those it
// took away; null when that changes nothing.
Future<Set<String>?> retagged(
List<String> now,
ReleaseEntry before,
ReleaseEntry after,
) async {
final lower = {for (final name in now) name.toLowerCase(): name};
final was = {for (final name in before.tags) name.toLowerCase()};
final will = {for (final name in after.tags) name.toLowerCase(): name};
final names = {
for (final MapEntry(:key, value: name) in lower.entries)
if (!was.contains(key) || will.containsKey(key)) key: name,
for (final MapEntry(:key, value: name) in will.entries)
if (!was.contains(key)) key: lower[key] ?? name,
};
if (names.keys.toSet().difference(lower.keys.toSet()).isEmpty &&
lower.keys.toSet().difference(names.keys.toSet()).isEmpty) {
return null;
}
return await tagIdsOf(names.values);
}
// Studysets: the top one always, to say which release it holds now.
for (final entry in diff.changed.where(_is(ReleaseKind.studyset))) {
final studyset = copy.studysets[entry.id]!;
await _studysets.updateContent(
studyset.id,
_withOrigin(
decodeStudysetPayload(
_bytes(
_threeWay(
owned(diff.base[entry.id]!.payload),
owned(entry.payload),
_json(encodeStudysetPayload(studyset.content)),
),
),
),
studyset.content.published!.taking(version),
location: studyset.content.publishedAt,
),
);
}
if (!diff.changed.any((entry) => copy.studysets[entry.id]?.id == rootId)) {
final root = copy.studysets.values.firstWhere(
(studyset) => studyset.id == rootId,
);
await _studysets.updateContent(
rootId,
_withOrigin(
root.content,
root.content.published!.taking(version),
location: root.content.publishedAt,
),
);
}
for (final entry in diff.added.where(_is(ReleaseKind.studyset))) {
final content = decodeStudysetPayload(_bytes(owned(entry.payload)));
final id = await _studysets.create(
_withOrigin(content, _origin(newer, entry.id)),
parentId: copy.studysets[entry.container]!.id,
);
copy.studysets[entry.id] = Studyset(
id: id,
content: content,
parentId: copy.studysets[entry.container]!.id,
);
}
// Cards.
for (final entry in diff.changed.where(_is(ReleaseKind.card))) {
final card = copy.cards[entry.id]!;
final content = decodeCardPayload(_bytes(owned(entry.payload)));
count(
await _cards.takeRelease(
card.id,
(base) => content.withField(
CardField.published,
base.published!.taking(version),
),
),
);
if (await retagged(
[for (final tag in tagging.of(card.id)) tag.name],
diff.base[entry.id]!,
entry,
)
case final ids?) {
await _tags.setTagsOf(card.id, ids);
}
}
final addedCards = diff.added.where(_is(ReleaseKind.card)).toList()
..sort(_byPosition);
for (final MapEntry(key: studysetItem, value: entries) in {
for (final entry in addedCards) entry.container!: <ReleaseEntry>[],
}.entries) {
entries.addAll(
addedCards.where((entry) => entry.container == studysetItem),
);
final ids = await _cards.createAll(copy.studysets[studysetItem]!.id, [
for (final entry in entries)
decodeCardPayload(
_bytes(owned(entry.payload)),
).withField(CardField.published, _origin(newer, entry.id)),
]);
await _tags.addTags({
for (final (i, entry) in entries.indexed)
if (entry.tags.isNotEmpty) ids[i]: await tagIdsOf(entry.tags),
});
}
// Notes.
for (final entry in diff.changed.where(_is(ReleaseKind.note))) {
final note = copy.notes[entry.id]!;
final content = decodeNotePayload(_bytes(owned(entry.payload)));
count(
await _notes.takeRelease(
note.id,
(base) => content.withField(
NoteField.published,
base.published!.taking(version),
),
),
);
if (await retagged(
[for (final tag in tagging.ofNote(note.id)) tag.name],
diff.base[entry.id]!,
entry,
)
case final ids?) {
await _tags.setTagsOfNote(note.id, ids);
}
}
for (final entry
in diff.added.where(_is(ReleaseKind.note)).toList()
..sort(_byPosition)) {
final (:noteId, revisionId: _) = await _notes.create(
copy.studysets[entry.container]!.id,
decodeNotePayload(
_bytes(owned(entry.payload)),
).withField(NoteField.published, _origin(newer, entry.id)),
);
if (entry.tags.isNotEmpty) {
await _tags.setTagsOfNote(noteId, await tagIdsOf(entry.tags));
}
}
// Learning paths, then their steps.
for (final entry in diff.changed.where(_is(ReleaseKind.path))) {
final path = copy.paths[entry.id]!;
final content = decodeLearningPathPayload(
_bytes(
_threeWay(
owned(diff.base[entry.id]!.payload),
owned(entry.payload),
_json(encodeLearningPathPayload(path.content)),
),
),
);
await _paths.updatePath(
path.id,
_pathWithOrigin(content, path.content.published!.taking(version)),
);
if (await retagged(
[for (final tag in tagging.ofPath(path.id)) tag.name],
diff.base[entry.id]!,
entry,
)
case final ids?) {
await _tags.setTagsOfPath(path.id, ids);
}
}
final addedPaths = <String, String>{};
for (final entry
in diff.added.where(_is(ReleaseKind.path)).toList()
..sort(_byPosition)) {
final id = addedPaths[entry.id] = await _paths.createPath(
copy.studysets[entry.container]!.id,
_pathWithOrigin(
decodeLearningPathPayload(_bytes(owned(entry.payload))),
_origin(newer, entry.id),
),
);
if (entry.tags.isNotEmpty) {
await _tags.setTagsOfPath(id, await tagIdsOf(entry.tags));
}
}
for (final entry in diff.changed.where(_is(ReleaseKind.step))) {
final step = copy.steps[entry.id]!;
final content = decodeStepPayload(
_bytes(owned(entry.payload)),
step.kind,
);
count(
await _paths.takeStepRelease(
step.id,
step.kind,
(base) => content.withField(
StepField.published,
base.published!.taking(version),
),
),
);
}
for (final entry
in diff.added.where(_is(ReleaseKind.step)).toList()
..sort(_byPosition)) {
final kind = StepKind.of(entry.stepKind ?? '');
final pathId =
copy.paths[entry.container]?.id ?? addedPaths[entry.container];
if (kind == null || pathId == null) {
continue;
}
await _paths.createStep(
pathId,
kind,
decodeStepPayload(
_bytes(owned(entry.payload)),
kind,
).withField(StepField.published, _origin(newer, entry.id)),
);
}
// PDFs.
for (final entry in diff.changed.where(_is(ReleaseKind.pdf))) {
final pdf = copy.pdfs[entry.id]!;
await _pdfs.update(
pdf.id,
_pdfWithOrigin(
decodePdfSourcePayload(
_bytes(
_threeWay(
owned(diff.base[entry.id]!.payload),
owned(entry.payload),
_json(encodePdfSourcePayload(pdf.content)),
),
),
),
pdf.content.published!.taking(version),
),
);
}
for (final entry
in diff.added.where(_is(ReleaseKind.pdf)).toList()..sort(_byPosition)) {
await _pdfs.add(
copy.studysets[entry.container]!.id,
_pdfWithOrigin(
decodePdfSourcePayload(_bytes(owned(entry.payload))),
_origin(newer, entry.id),
),
);
}
// What the author withdrew stays the learner's, marked.
for (final entry in diff.withdrawn) {
switch (entry.kind) {
case ReleaseKind.studyset:
final studyset = copy.studysets[entry.id]!;
await _studysets.updateContent(
studyset.id,
_withOrigin(
studyset.content,
studyset.content.published!.withdrawnIn(version),
location: studyset.content.publishedAt,
),
);
case ReleaseKind.card:
count(
await _cards.takeRelease(
copy.cards[entry.id]!.id,
(base) => base.withField(
CardField.published,
base.published!.withdrawnIn(version),
),
),
);
case ReleaseKind.note:
count(
await _notes.takeRelease(
copy.notes[entry.id]!.id,
(base) => base.withField(
NoteField.published,
base.published!.withdrawnIn(version),
),
),
);
case ReleaseKind.path:
final path = copy.paths[entry.id]!;
await _paths.updatePath(
path.id,
_pathWithOrigin(
path.content,
path.content.published!.withdrawnIn(version),
),
);
case ReleaseKind.step:
final step = copy.steps[entry.id]!;
count(
await _paths.takeStepRelease(
step.id,
step.kind,
(base) => base.withField(
StepField.published,
base.published!.withdrawnIn(version),
),
),
);
case ReleaseKind.pdf:
final pdf = copy.pdfs[entry.id]!;
await _pdfs.update(
pdf.id,
_pdfWithOrigin(
pdf.content,
pdf.content.published!.withdrawnIn(version),
),
);
}
}
return toSettle;
}
/// The items of the copy whose top studyset is [rootId] that came from
/// the published studyset [publication], by their ids in its releases.
Future<_Copy> _copyOf(String rootId, String publication) async {
final copy = _Copy();
bool from(PublishedRef? origin) => origin?.publication == publication;
final all = await _studysets.all();
for (final id in subtreeOf(rootId, all)) {
final studyset = all.firstWhere((each) => each.id == id);
if (studyset.content.published case final origin? when from(origin)) {
copy.studysets[origin.item] = studyset;
}
for (final card in await _cards.cardsOf(id)) {
if (card.content.published case final origin? when from(origin)) {
copy.cards[origin.item] = card;
}
}
for (final note in await _notes.notesOf(id)) {
if (note.content.published case final origin? when from(origin)) {
copy.notes[origin.item] = note;
}
}
for (final path in await _paths.pathsOf(id)) {
if (path.content.published case final origin? when from(origin)) {
copy.paths[origin.item] = path;
}
for (final step in await _paths.stepsOf(path.id)) {
if (step.content.published case final origin? when from(origin)) {
copy.steps[origin.item] = step;
}
}
}
for (final pdf in await _pdfs.sourcesOf(id)) {
if (pdf.content.published case final origin? when from(origin)) {
copy.pdfs[origin.item] = pdf;
}
}
}
return copy;
}
static bool Function(ReleaseEntry entry) _is(ReleaseKind kind) =>
(entry) => entry.kind == kind;
static int _byPosition(ReleaseEntry a, ReleaseEntry b) =>
switch (a.position.compareTo(b.position)) {
0 => a.id.compareTo(b.id),
final order => order,
};
static PublishedRef _origin(PublishedRelease release, String item) =>
PublishedRef(
publication: release.publicId,
item: item,
version: release.version,
);
/// What the learner's [mine] becomes with the release's [newer] taken
/// from [base], each field (a key of the payload) on its own: one the
/// learner left as [base] had it takes [newer]'s, one they changed stays
/// theirs. The schema stays the app's.
static Map<String, Object?> _threeWay(
Map<String, Object?> base,
Map<String, Object?> newer,
Map<String, Object?> mine,
) => {
for (final key in {...base.keys, ...newer.keys, ...mine.keys})
if (key == 'schemaVersion')
key: mine[key]
else if (jsonEncode(mine[key]) == jsonEncode(base[key])) ...{
if (newer.containsKey(key)) key: newer[key],
} else if (mine.containsKey(key))
key: mine[key],
};
static Map<String, Object?> _json(ByteData payload) =>
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
as Map<String, Object?>;
static ByteData _bytes(Map<String, Object?> json) =>
ByteData.sublistView(utf8.encode(jsonEncode(json)));
static StudysetContent _withOrigin(
StudysetContent content,
PublishedRef origin, {
String? location,
}) => StudysetContent(
title: content.title,
description: content.description,
secondaryTitle: content.secondaryTitle,
colorName: content.colorName,
license: content.license,
licenseHolder: content.licenseHolder,
licenseYear: content.licenseYear,
frontLanguage: content.frontLanguage,
backLanguage: content.backLanguage,
studyOptions: content.studyOptions,
bibliography: content.bibliography,
published: origin,
publishedAt: location,
);
static LearningPathContent _pathWithOrigin(
LearningPathContent content,
PublishedRef origin,
) => LearningPathContent(
title: content.title,
secondaryTitle: content.secondaryTitle,
description: content.description,
published: origin,
);
static PdfSourceContent _pdfWithOrigin(
PdfSourceContent content,
PublishedRef origin,
) => PdfSourceContent(
title: content.title,
fileName: content.fileName,
assetId: content.assetId,
pageCount: content.pageCount,
deadline: content.deadline,
published: origin,
);
}
/// A copy's items that came from the published studyset, by their ids in
/// its releases.
final class _Copy() {
final studysets = <String, Studyset>{};
final cards = <String, Card>{};
final notes = <String, Note>{};
final paths = <String, LearningPath>{};
final steps = <String, LearningPathStep>{};
final pdfs = <String, PdfSource>{};
/// Whether the copy holds the item [id] of [kind], not withdrawn.
bool holds(ReleaseKind kind, String id) => switch (kind) {
ReleaseKind.studyset => _live(studysets[id]?.content.published),
ReleaseKind.card => _live(cards[id]?.content.published),
ReleaseKind.note => _live(notes[id]?.content.published),
ReleaseKind.path => _live(paths[id]?.content.published),
ReleaseKind.step => _live(steps[id]?.content.published),
ReleaseKind.pdf => _live(pdfs[id]?.content.published),
};
static bool _live(PublishedRef? origin) =>
origin != null && !origin.withdrawn;
}
/// What a newer release changes against the base one, for a copy: the
/// items it changed that the copy holds, the items new in it whose
/// studyset (a step's path) the copy holds or gets, and the items it no
/// longer has that the copy holds.
final class _Diff(
_Copy copy,
final Map<String, ReleaseEntry> base,
Map<String, ReleaseEntry> newer,
) {
this {
for (final entry in newer.values) {
if (base[entry.id] case final before?) {
if (copy.holds(entry.kind, entry.id) &&
(jsonEncode(before.payload) != jsonEncode(entry.payload) ||
before.tags.join('\n') != entry.tags.join('\n'))) {
changed.add(entry);
}
}
}
// New items, each once what it is in is the copy's or comes too: a
// studyset inside a new one, a step of a new path.
final candidates = [
for (final entry in newer.values)
if (!base.containsKey(entry.id)) entry,
];
final coming = <String>{};
var grew = true;
while (grew) {
grew = false;
for (final entry in candidates) {
if (coming.contains(entry.id)) {
continue;
}
final container = entry.container;
final inside = entry.kind == ReleaseKind.step
? ReleaseKind.path
: ReleaseKind.studyset;
if (container != null &&
(copy.holds(inside, container) || coming.contains(container))) {
coming.add(entry.id);
added.add(entry);
grew = true;
}
}
}
for (final entry in base.values) {
if (!newer.containsKey(entry.id) && copy.holds(entry.kind, entry.id)) {
withdrawn.add(entry);
}
}
// A new studyset before what goes into it.
added.sort(
(a, b) => _depth(a, newer).compareTo(_depth(b, newer)),
);
}
final changed = <ReleaseEntry>[];
final added = <ReleaseEntry>[];
final withdrawn = <ReleaseEntry>[];
static int _depth(ReleaseEntry entry, Map<String, ReleaseEntry> all) {
var depth = 0;
for (var at = entry.container; at != null; at = all[at]?.container) {
depth++;
if (depth > all.length) {
break;
}
}
return depth;
}
}

View file

@ -15,8 +15,9 @@ abstract interface class ExploreGateway() {
/// left out. /// left out.
Future<List<ExploreStudyset>> entries(List<String> publicIds); Future<List<ExploreStudyset>> entries(List<String> publicIds);
/// The latest release of the published studyset [publicId]. /// Release [version] of the published studyset [publicId], else its
Future<PublishedRelease> release(String publicId); /// latest.
Future<PublishedRelease> release(String publicId, {int? version});
/// The file [sha256] of release [version] of [publicId]. /// The file [sha256] of release [version] of [publicId].
Future<Uint8List> file(String publicId, int version, String sha256); Future<Uint8List> file(String publicId, int version, String sha256);

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