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:
parent
f4af6cfd3a
commit
5613530f82
38 changed files with 2230 additions and 93 deletions
|
|
@ -684,7 +684,7 @@
|
|||
"composerDiscardTitle": "{count, plural, =1{Nicht gespeicherte Karteikarte verwerfen?} other{Nicht gespeicherte Karteikarten verwerfen?}}",
|
||||
"composerDiscardMessage": "{count, plural, =1{1 Karteikarte wurde noch nicht gespeichert. Wenn du jetzt gehst, wird sie verworfen.} other{{count} Karteikarten wurden noch nicht gespeichert. Wenn du jetzt gehst, werden sie verworfen.}}",
|
||||
"composerDiscardConfirm": "Verwerfen",
|
||||
"composerConflict": "Diese Karteikarte wurde auf zwei 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",
|
||||
"formatAlignLeft": "Linksbündig",
|
||||
"formatAlignCenter": "Zentriert",
|
||||
|
|
@ -1431,5 +1431,15 @@
|
|||
"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.",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {"placeholders": {"count": {"type": "int"}}},
|
||||
"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",
|
||||
"formatAlignLeft": "Align left",
|
||||
"formatAlignCenter": "Centre",
|
||||
|
|
@ -1670,5 +1670,21 @@
|
|||
"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.",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4051,7 +4051,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @composerConflict.
|
||||
///
|
||||
/// 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;
|
||||
|
||||
/// No description provided for @composerKeepThis.
|
||||
|
|
@ -8498,6 +8498,66 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'Taken off Explore.'**
|
||||
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
|
||||
|
|
|
|||
|
|
@ -2459,7 +2459,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
String get composerKeepThis => 'Diese behalten';
|
||||
|
|
@ -5315,4 +5315,55 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2442,7 +2442,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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
|
||||
String get composerKeepThis => 'Keep this';
|
||||
|
|
@ -5270,6 +5270,54 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@override
|
||||
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`).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
/// Null when it can't be had now: no such row, no connection, or not
|
||||
/// uploaded yet.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,40 @@
|
|||
/// 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.
|
||||
typedef PublishedRef = ({String publication, String item, int version});
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// [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) => {
|
||||
'publication': ref.publication,
|
||||
'item': ref.item,
|
||||
'version': ref.version,
|
||||
if (ref.withdrawn) 'withdrawn': true,
|
||||
};
|
||||
|
||||
/// 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,
|
||||
}
|
||||
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.'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
_ => 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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/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/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/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
|
||||
|
|
@ -310,6 +311,7 @@ Future<PersonDependencies> _openPerson(
|
|||
explore: ExploreRepository(
|
||||
ServerpodExploreGateway(client),
|
||||
ReleaseCopier(studysets, cards, notes, paths, pdfSources, tags, assets),
|
||||
CopyUpdater(studysets, cards, notes, paths, pdfSources, tags, assets),
|
||||
studysets,
|
||||
),
|
||||
onDispose: () async {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import 'dart:async';
|
|||
import 'dart:developer';
|
||||
|
||||
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/feature/card/data/card_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
||||
|
|
@ -20,19 +21,6 @@ typedef CardStamp = ({
|
|||
bool imported,
|
||||
});
|
||||
|
||||
/// What taking a dictionary's correction into a card came to.
|
||||
enum UpdateTaken() {
|
||||
/// Nothing: the card can't take it.
|
||||
none,
|
||||
|
||||
/// Taken whole.
|
||||
taken,
|
||||
|
||||
/// Taken, but the person changed a side the correction changed too: a
|
||||
/// conflict to settle.
|
||||
toSettle,
|
||||
}
|
||||
|
||||
/// The person's flashcards: content kept as append-only revisions (ADR 0001,
|
||||
/// rule 2), merged per side when devices edited offline, ordered by a
|
||||
/// fractional index (decided 2026-09-30).
|
||||
|
|
@ -246,23 +234,51 @@ class CardRepository(final CardGateway _gateway) {
|
|||
Future<UpdateTaken> takeDictionaryUpdate(
|
||||
String cardId,
|
||||
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 {
|
||||
// Revision ids sort by when they were written.
|
||||
final revisions = [
|
||||
for (final row in await _gateway.revisionsOf({cardId})) ?_decode(row),
|
||||
]..sort((a, b) => a.id.compareTo(b.id));
|
||||
final now = mergeRevisions(revisions)?.content;
|
||||
final entry = now?.entry;
|
||||
final version = now?.entryVersion;
|
||||
if (entry == null || version == null) {
|
||||
final key = switch (mergeRevisions(revisions)) {
|
||||
final merged? => keyOf(merged.content),
|
||||
null => null,
|
||||
};
|
||||
if (key == null) {
|
||||
return UpdateTaken.none;
|
||||
}
|
||||
final base = revisions
|
||||
.where(
|
||||
(revision) =>
|
||||
revision.content.entry == entry &&
|
||||
revision.content.entryVersion == version,
|
||||
)
|
||||
.where((revision) => keyOf(revision.content) == key)
|
||||
.firstOrNull;
|
||||
if (base == null) {
|
||||
return UpdateTaken.none;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,10 @@ enum CardStatus() {
|
|||
|
||||
/// Reported as wrong, not resolved yet.
|
||||
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.
|
||||
|
|
@ -155,6 +159,7 @@ bool isInState(
|
|||
},
|
||||
CardStatus.buried => card.card.isBuried(startOfToday),
|
||||
CardStatus.reported => card.card.content.report != null,
|
||||
CardStatus.withdrawn => card.card.content.published?.withdrawn ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,9 +121,17 @@ class _FilterSheetState() extends State<_FilterSheet> {
|
|||
children: [
|
||||
for (final status in CardStatus.values)
|
||||
FilterChip(
|
||||
avatar: status == CardStatus.reported
|
||||
? const Icon(LucideIcons.flag, size: 16)
|
||||
: null,
|
||||
avatar: switch (status) {
|
||||
CardStatus.reported => const Icon(
|
||||
LucideIcons.flag,
|
||||
size: 16,
|
||||
),
|
||||
CardStatus.withdrawn => const Icon(
|
||||
LucideIcons.archiveX,
|
||||
size: 16,
|
||||
),
|
||||
_ => null,
|
||||
},
|
||||
label: Text(_statusName(l10n, status)),
|
||||
selected: _states.contains(status),
|
||||
onSelected: (on) => setState(
|
||||
|
|
@ -156,6 +164,7 @@ String _statusName(AppLocalizations l10n, CardStatus status) =>
|
|||
CardStatus.due => l10n.cardBrowserStatusDue,
|
||||
CardStatus.buried => l10n.cardBrowserStatusBuried,
|
||||
CardStatus.reported => l10n.cardBrowserStatusReported,
|
||||
CardStatus.withdrawn => l10n.cardBrowserStatusWithdrawn,
|
||||
};
|
||||
|
||||
/// Asks which tags to give [count] chosen cards, among all the person's;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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/editor/rich_content_view.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),
|
||||
),
|
||||
(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),
|
||||
),
|
||||
_ => Text('${version ?? ''}'),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'dart:developer';
|
|||
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/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_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_updates.dart';
|
||||
|
|
|
|||
|
|
@ -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/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -15,8 +15,9 @@ abstract interface class ExploreGateway() {
|
|||
/// left out.
|
||||
Future<List<ExploreStudyset>> entries(List<String> publicIds);
|
||||
|
||||
/// The latest release of the published studyset [publicId].
|
||||
Future<PublishedRelease> release(String publicId);
|
||||
/// Release [version] of the published studyset [publicId], else its
|
||||
/// latest.
|
||||
Future<PublishedRelease> release(String publicId, {int? version});
|
||||
|
||||
/// The file [sha256] of release [version] of [publicId].
|
||||
Future<Uint8List> file(String publicId, int version, String sha256);
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/copy_updater.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
|
|
@ -10,8 +12,19 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_conten
|
|||
class ExploreRepository(
|
||||
final ExploreGateway _gateway,
|
||||
final ReleaseCopier _copier,
|
||||
final CopyUpdater _updater,
|
||||
final StudysetRepository _studysets,
|
||||
) {
|
||||
/// Releases fetched, by published studyset and version: a release never
|
||||
/// changes, so one fetched for an offer serves taking it.
|
||||
final _releases = <(String, int?), PublishedRelease>{};
|
||||
|
||||
Future<PublishedRelease> _release(String publicId, int version) async =>
|
||||
_releases[(publicId, version)] ??= await _gateway.release(
|
||||
publicId,
|
||||
version: version,
|
||||
);
|
||||
|
||||
/// A page of published studysets answering [filter], the newest release
|
||||
/// first, [offset] of them skipped; whether more may follow.
|
||||
Future<({List<ExploreStudyset> page, bool hasMore})> list(
|
||||
|
|
@ -27,12 +40,67 @@ class ExploreRepository(
|
|||
Future<Map<String, String>> copies() async => {
|
||||
for (final studyset in await _studysets.all())
|
||||
if (studyset.content case StudysetContent(
|
||||
published: (:final publication, item: _, version: _),
|
||||
published: PublishedRef(:final publication),
|
||||
publishedAt: _?,
|
||||
))
|
||||
publication: studyset.id,
|
||||
};
|
||||
|
||||
/// What the latest release of the published studyset the person's copy
|
||||
/// [rootId] (its top studyset) came from would bring it; null while the
|
||||
/// copy holds the latest, and for a studyset its author withdrew, whose
|
||||
/// copies get no updates.
|
||||
Future<CopyUpdate?> updateOf(String rootId) async {
|
||||
final origin = await _originOf(rootId);
|
||||
if (origin == null) {
|
||||
return null;
|
||||
}
|
||||
final latest = switch (await _gateway.entries([origin.publication])) {
|
||||
[final studyset] => studyset.version,
|
||||
_ => null,
|
||||
};
|
||||
if (latest == null || latest <= origin.version) {
|
||||
return null;
|
||||
}
|
||||
return await _updater.plan(
|
||||
rootId,
|
||||
await _release(origin.publication, origin.version),
|
||||
await _release(origin.publication, latest),
|
||||
);
|
||||
}
|
||||
|
||||
/// Takes the release [version] into the person's copy [rootId], from the
|
||||
/// one it holds (see [CopyUpdater]); returns how many cards, notes and
|
||||
/// steps have a conflict to settle now.
|
||||
Future<int> takeUpdate(String rootId, int version) async {
|
||||
final origin = await _originOf(rootId);
|
||||
if (origin == null || version <= origin.version) {
|
||||
return 0;
|
||||
}
|
||||
return await _updater.take(
|
||||
rootId,
|
||||
await _release(origin.publication, origin.version),
|
||||
await _release(origin.publication, version),
|
||||
fileOf: (sha256) => _gateway.file(origin.publication, version, sha256),
|
||||
);
|
||||
}
|
||||
|
||||
/// Where the copy [rootId] came from; null for a studyset of the
|
||||
/// person's own.
|
||||
Future<PublishedRef?> _originOf(String rootId) async {
|
||||
for (final studyset in await _studysets.all()) {
|
||||
if (studyset.id == rootId) {
|
||||
return switch (studyset.content) {
|
||||
StudysetContent(published: final origin?, publishedAt: _?)
|
||||
when !origin.withdrawn =>
|
||||
origin,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Copies the latest release of [studyset] into the person's library and
|
||||
/// returns the copy's top studyset; tells [onProgress] how much is done,
|
||||
/// from 0 to 1. Throws [ReleaseUnreadable] for a release this app can't
|
||||
|
|
|
|||
|
|
@ -97,8 +97,11 @@ class ReleaseCopier(
|
|||
step();
|
||||
}
|
||||
final read = _read(release, assetIds);
|
||||
PublishedRef origin(String id) =>
|
||||
(publication: release.publicId, item: id, version: release.version);
|
||||
PublishedRef origin(String id) => PublishedRef(
|
||||
publication: release.publicId,
|
||||
item: id,
|
||||
version: release.version,
|
||||
);
|
||||
|
||||
// The studysets, each after the one it is in, in their order.
|
||||
final studysetIds = <String, String>{};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue