fix(explore): releases in parts, and say what kept a copy from being made

A language's release, Japanese's thousands of words, came as one answer
that a phone couldn't fetch within the client's 20 seconds. The instance
now also gives a release out in parts of at most 500 files
(`releasePart`), the first naming the version the rest ask for; the app
fetches it part by part, its progress the first half of adding, asks
again for a part whose connection dropped, and takes an older instance's
release whole. Older apps keep the one-piece call.

Explore said "Couldn't reach your instance" whatever went wrong. It now
says whether the instance couldn't be reached, answered with an error,
or the copy couldn't be written on the device.

A copy's tags are written with one read for each studyset rather than
two for each tag name: 85 reads of every card's tags for a release of
40 cards are now at most 4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-16 21:27:55 +02:00
commit 838f753bf2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 1097 additions and 60 deletions

File diff suppressed because one or more lines are too long

View file

@ -46,6 +46,8 @@ import 'package:halkyon_learn_client/src/protocol/explore/explore_query.dart'
as _i728kten; as _i728kten;
import 'package:halkyon_learn_client/src/protocol/explore/release_content.dart' import 'package:halkyon_learn_client/src/protocol/explore/release_content.dart'
as _icu7niot; as _icu7niot;
import 'package:halkyon_learn_client/src/protocol/explore/release_part.dart'
as _ijv4vyoq;
import 'package:halkyon_learn_client/src/protocol/feedback/feedback_view.dart' import 'package:halkyon_learn_client/src/protocol/feedback/feedback_view.dart'
as _idpwh21d; as _idpwh21d;
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart' import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
@ -774,6 +776,30 @@ class EndpointExplore extends _isc.EndpointRef {
}, },
); );
/// [count] of the JSON files of release [version] of the published
/// studyset [publicId], else of its latest, from the [offset]th in the
/// order of their paths (decided 2026-10-16): a large release in parts,
/// each within a request's time on a phone, the first naming the version
/// the others ask for; at most [releasePartMost] files a part. Throws
/// [NotFoundException] for a release that doesn't exist, a studyset
/// withdrawn or never published, or a part before the first file or of
/// none.
_ida.Future<_ijv4vyoq.ReleasePart> releasePart(
_isc.UuidValue publicId, {
int? version,
required int offset,
required int count,
}) => caller.callServerEndpoint<_ijv4vyoq.ReleasePart>(
'explore',
'releasePart',
{
'publicId': publicId,
'version': version,
'offset': offset,
'count': count,
},
);
/// 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
/// hold, or a studyset withdrawn or never published. /// hold, or a studyset withdrawn or never published.

View file

@ -0,0 +1,143 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../explore/release_item.dart' as _i335gjz4;
/// A part of a release's content (decided 2026-10-16): a large release, a
/// language's thousands of words, is given out in parts, each well within
/// a request's time on a phone. Its JSON files from where asked, in the
/// order of their paths, how many the release holds, and the files their
/// content names.
abstract class ReleasePart
implements _isc.SerializableModel, _isc.ProtocolSerialization {
ReleasePart._({
required this.publicId,
required this.version,
required this.total,
required this.items,
required this.files,
});
factory ReleasePart({
required _isc.UuidValue publicId,
required int version,
required int total,
required List<_i335gjz4.ReleaseItem> items,
required List<String> files,
}) = _ReleasePartImpl;
factory ReleasePart.fromJson(Map<String, dynamic> jsonSerialization) {
return ReleasePart(
publicId: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['publicId'],
),
version: jsonSerialization['version'] as int,
total: jsonSerialization['total'] as int,
items: _itx5j3f5.Protocol().deserialize<List<_i335gjz4.ReleaseItem>>(
jsonSerialization['items'],
),
files: _itx5j3f5.Protocol().deserialize<List<String>>(
jsonSerialization['files'],
),
);
}
_isc.UuidValue publicId;
int version;
/// How many JSON files the release holds in all.
int total;
/// The JSON files of this part, in the order of their paths.
List<_i335gjz4.ReleaseItem> items;
/// The files the content names, by SHA-256: `files.json` describes them.
List<String> files;
/// Returns a shallow copy of this [ReleasePart]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
ReleasePart copyWith({
_isc.UuidValue? publicId,
int? version,
int? total,
List<_i335gjz4.ReleaseItem>? items,
List<String>? files,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'ReleasePart',
'publicId': publicId.toJson(),
'version': version,
'total': total,
'items': items.toJson(valueToJson: (v) => v.toJson()),
'files': files.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'ReleasePart',
'publicId': publicId.toJson(),
'version': version,
'total': total,
'items': items.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'files': files.toJson(),
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _ReleasePartImpl extends ReleasePart {
_ReleasePartImpl({
required _isc.UuidValue publicId,
required int version,
required int total,
required List<_i335gjz4.ReleaseItem> items,
required List<String> files,
}) : super._(
publicId: publicId,
version: version,
total: total,
items: items,
files: files,
);
/// Returns a shallow copy of this [ReleasePart]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
ReleasePart copyWith({
_isc.UuidValue? publicId,
int? version,
int? total,
List<_i335gjz4.ReleaseItem>? items,
List<String>? files,
}) {
return ReleasePart(
publicId: publicId ?? this.publicId,
version: version ?? this.version,
total: total ?? this.total,
items: items ?? this.items.map((e0) => e0.copyWith()).toList(),
files: files ?? this.files.map((e0) => e0).toList(),
);
}
}

View file

@ -70,6 +70,7 @@ import 'explore/explore_entry.dart' as _iaxdli27;
import 'explore/explore_query.dart' as _ibl9h9o9; import 'explore/explore_query.dart' as _ibl9h9o9;
import 'explore/release_content.dart' as _ievpeb4f; import 'explore/release_content.dart' as _ievpeb4f;
import 'explore/release_item.dart' as _iaswq7ap; import 'explore/release_item.dart' as _iaswq7ap;
import 'explore/release_part.dart' as _i0tkr6he;
import 'feedback/feedback_decision.dart' as _idsmz0xa; import 'feedback/feedback_decision.dart' as _idsmz0xa;
import 'feedback/feedback_message.dart' as _iqqrr7at; import 'feedback/feedback_message.dart' as _iqqrr7at;
import 'feedback/feedback_message_view.dart' as _inlv2ksv; import 'feedback/feedback_message_view.dart' as _inlv2ksv;
@ -132,6 +133,7 @@ export 'explore/explore_entry.dart';
export 'explore/explore_query.dart'; export 'explore/explore_query.dart';
export 'explore/release_content.dart'; export 'explore/release_content.dart';
export 'explore/release_item.dart'; export 'explore/release_item.dart';
export 'explore/release_part.dart';
export 'feedback/feedback_decision.dart'; export 'feedback/feedback_decision.dart';
export 'feedback/feedback_message.dart'; export 'feedback/feedback_message.dart';
export 'feedback/feedback_message_view.dart'; export 'feedback/feedback_message_view.dart';
@ -2408,6 +2410,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _iaswq7ap.ReleaseItem) { if (t == _iaswq7ap.ReleaseItem) {
return _iaswq7ap.ReleaseItem.fromJson(data) as T; return _iaswq7ap.ReleaseItem.fromJson(data) as T;
} }
if (t == _i0tkr6he.ReleasePart) {
return _i0tkr6he.ReleasePart.fromJson(data) as T;
}
if (t == _idsmz0xa.FeedbackDecision) { if (t == _idsmz0xa.FeedbackDecision) {
return _idsmz0xa.FeedbackDecision.fromJson(data) as T; return _idsmz0xa.FeedbackDecision.fromJson(data) as T;
} }
@ -2632,6 +2637,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_iaswq7ap.ReleaseItem?>()) { if (t == _isc.getType<_iaswq7ap.ReleaseItem?>()) {
return (data != null ? _iaswq7ap.ReleaseItem.fromJson(data) : null) as T; return (data != null ? _iaswq7ap.ReleaseItem.fromJson(data) : null) as T;
} }
if (t == _isc.getType<_i0tkr6he.ReleasePart?>()) {
return (data != null ? _i0tkr6he.ReleasePart.fromJson(data) : null) as T;
}
if (t == _isc.getType<_idsmz0xa.FeedbackDecision?>()) { if (t == _isc.getType<_idsmz0xa.FeedbackDecision?>()) {
return (data != null ? _idsmz0xa.FeedbackDecision.fromJson(data) : null) return (data != null ? _idsmz0xa.FeedbackDecision.fromJson(data) : null)
as T; as T;
@ -2898,6 +2906,7 @@ class Protocol extends _isd.DatabaseSerializationManager {
_ibl9h9o9.ExploreQuery => 'ExploreQuery', _ibl9h9o9.ExploreQuery => 'ExploreQuery',
_ievpeb4f.ReleaseContent => 'ReleaseContent', _ievpeb4f.ReleaseContent => 'ReleaseContent',
_iaswq7ap.ReleaseItem => 'ReleaseItem', _iaswq7ap.ReleaseItem => 'ReleaseItem',
_i0tkr6he.ReleasePart => 'ReleasePart',
_idsmz0xa.FeedbackDecision => 'FeedbackDecision', _idsmz0xa.FeedbackDecision => 'FeedbackDecision',
_iqqrr7at.FeedbackMessage => 'FeedbackMessage', _iqqrr7at.FeedbackMessage => 'FeedbackMessage',
_inlv2ksv.FeedbackMessageView => 'FeedbackMessageView', _inlv2ksv.FeedbackMessageView => 'FeedbackMessageView',
@ -3010,6 +3019,8 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'ReleaseContent'; return 'ReleaseContent';
case _iaswq7ap.ReleaseItem(): case _iaswq7ap.ReleaseItem():
return 'ReleaseItem'; return 'ReleaseItem';
case _i0tkr6he.ReleasePart():
return 'ReleasePart';
case _idsmz0xa.FeedbackDecision(): case _idsmz0xa.FeedbackDecision():
return 'FeedbackDecision'; return 'FeedbackDecision';
case _iqqrr7at.FeedbackMessage(): case _iqqrr7at.FeedbackMessage():
@ -3195,6 +3206,9 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'ReleaseItem') { if (dataClassName == 'ReleaseItem') {
return deserialize<_iaswq7ap.ReleaseItem>(data['data']); return deserialize<_iaswq7ap.ReleaseItem>(data['data']);
} }
if (dataClassName == 'ReleasePart') {
return deserialize<_i0tkr6he.ReleasePart>(data['data']);
}
if (dataClassName == 'FeedbackDecision') { if (dataClassName == 'FeedbackDecision') {
return deserialize<_idsmz0xa.FeedbackDecision>(data['data']); return deserialize<_idsmz0xa.FeedbackDecision>(data['data']);
} }

View file

@ -1689,6 +1689,8 @@
"exploreOpen": "Öffnen", "exploreOpen": "Öffnen",
"exploreNeedsNewerApp": "Dieses Lernset braucht eine neuere Version der App.", "exploreNeedsNewerApp": "Dieses Lernset braucht eine neuere Version der App.",
"exploreFailed": "Deine Instanz war nicht erreichbar. Versuche es noch einmal.", "exploreFailed": "Deine Instanz war nicht erreichbar. Versuche es noch einmal.",
"exploreNotGivenOut": "Deine Instanz konnte dieses Lernset nicht herausgeben. Versuche es später noch einmal.",
"exploreNotWritten": "Es konnte auf diesem Gerät nicht zu deiner Bibliothek hinzugefügt werden.",
"exploreUnreachable": "Entdecken ist gerade nicht erreichbar.", "exploreUnreachable": "Entdecken ist gerade nicht erreichbar.",
"exploreRetry": "Erneut versuchen", "exploreRetry": "Erneut versuchen",
"exploreMore": "Mehr anzeigen", "exploreMore": "Mehr anzeigen",

View file

@ -1980,6 +1980,8 @@
"exploreOpen": "Open", "exploreOpen": "Open",
"exploreNeedsNewerApp": "This studyset needs a newer version of the app.", "exploreNeedsNewerApp": "This studyset needs a newer version of the app.",
"exploreFailed": "Couldn't reach your instance. Try again.", "exploreFailed": "Couldn't reach your instance. Try again.",
"exploreNotGivenOut": "Your instance couldn't give out this studyset. Try again later.",
"exploreNotWritten": "Couldn't add it to your library on this device.",
"exploreUnreachable": "Explore can't be reached right now.", "exploreUnreachable": "Explore can't be reached right now.",
"exploreRetry": "Retry", "exploreRetry": "Retry",
"exploreMore": "Show more", "exploreMore": "Show more",

View file

@ -9999,6 +9999,18 @@ abstract class AppLocalizations {
/// **'Couldn\'t reach your instance. Try again.'** /// **'Couldn\'t reach your instance. Try again.'**
String get exploreFailed; String get exploreFailed;
/// No description provided for @exploreNotGivenOut.
///
/// In en, this message translates to:
/// **'Your instance couldn\'t give out this studyset. Try again later.'**
String get exploreNotGivenOut;
/// No description provided for @exploreNotWritten.
///
/// In en, this message translates to:
/// **'Couldn\'t add it to your library on this device.'**
String get exploreNotWritten;
/// No description provided for @exploreUnreachable. /// No description provided for @exploreUnreachable.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -6296,6 +6296,14 @@ class AppLocalizationsDe extends AppLocalizations {
String get exploreFailed => String get exploreFailed =>
'Deine Instanz war nicht erreichbar. Versuche es noch einmal.'; 'Deine Instanz war nicht erreichbar. Versuche es noch einmal.';
@override
String get exploreNotGivenOut =>
'Deine Instanz konnte dieses Lernset nicht herausgeben. Versuche es später noch einmal.';
@override
String get exploreNotWritten =>
'Es konnte auf diesem Gerät nicht zu deiner Bibliothek hinzugefügt werden.';
@override @override
String get exploreUnreachable => 'Entdecken ist gerade nicht erreichbar.'; String get exploreUnreachable => 'Entdecken ist gerade nicht erreichbar.';

View file

@ -6238,6 +6238,14 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get exploreFailed => 'Couldn\'t reach your instance. Try again.'; String get exploreFailed => 'Couldn\'t reach your instance. Try again.';
@override
String get exploreNotGivenOut =>
'Your instance couldn\'t give out this studyset. Try again later.';
@override
String get exploreNotWritten =>
'Couldn\'t add it to your library on this device.';
@override @override
String get exploreUnreachable => 'Explore can\'t be reached right now.'; String get exploreUnreachable => 'Explore can\'t be reached right now.';

View file

@ -2,6 +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/explore/data/explore_gateway.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/model/explore_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
@ -89,7 +90,7 @@ class ExploreBloc(final ExploreRepository _explore)
emit( emit(
state.copyWith( state.copyWith(
loadingMore: false, loadingMore: false,
outcome: const ExploreFailed(), outcome: const ExploreNotReached(),
), ),
); );
} }
@ -131,7 +132,11 @@ class ExploreBloc(final ExploreRepository _explore)
emit( emit(
state.copyWith( state.copyWith(
adding: {...state.adding}..remove(studyset.publicId), adding: {...state.adding}..remove(studyset.publicId),
outcome: const ExploreFailed(), outcome: switch (error) {
InstanceUnreachable() => const ExploreNotReached(),
InstanceFailed() => const ExploreNotGivenOut(),
_ => const ExploreNotWritten(),
},
), ),
); );
} }
@ -160,8 +165,16 @@ final class const ExploreAdded(final String studysetId) extends ExploreOutcome {
/// The release needs a newer version of the app; nothing was added. /// The release needs a newer version of the app; nothing was added.
final class const ExploreNeedsNewerApp() extends ExploreOutcome; final class const ExploreNeedsNewerApp() extends ExploreOutcome;
/// The instance couldn't be reached; nothing changed. /// The instance couldn't be reached, or didn't answer in time; nothing
final class const ExploreFailed() extends ExploreOutcome; /// changed.
final class const ExploreNotReached() extends ExploreOutcome;
/// The instance answered with an error: it couldn't give out the studyset,
/// so nothing was added.
final class const ExploreNotGivenOut() extends ExploreOutcome;
/// Writing the copy on this device failed.
final class const ExploreNotWritten() extends ExploreOutcome;
/// Explore as its screen shows it: the [filter] and what answers it, a /// Explore as its screen shows it: the [filter] and what answers it, a
/// page at a time, which of them the person has a copy of ([copies], by /// page at a time, which of them the person has a copy of ([copies], by

View file

@ -2,7 +2,16 @@ import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
/// The boundary to Explore on the instance. Tests replace it with a fake. /// The instance couldn't be reached: no connection, or no answer in time.
final class const InstanceUnreachable() implements Exception;
/// The instance answered, with an error: it couldn't give out what was
/// asked.
final class const InstanceFailed() implements Exception;
/// The boundary to Explore on the instance, which says what failed as
/// [InstanceUnreachable] or [InstanceFailed]. Tests replace it with a
/// fake.
abstract interface class ExploreGateway() { abstract interface class ExploreGateway() {
/// How many studysets a page holds at most: a shorter one is the last. /// How many studysets a page holds at most: a shorter one is the last.
int get pageSize; int get pageSize;
@ -16,8 +25,13 @@ abstract interface class ExploreGateway() {
Future<List<ExploreStudyset>> entries(List<String> publicIds); Future<List<ExploreStudyset>> entries(List<String> publicIds);
/// Release [version] of the published studyset [publicId], else its /// Release [version] of the published studyset [publicId], else its
/// latest. /// latest, telling [onFetched] how many of its files are here so far: a
Future<PublishedRelease> release(String publicId, {int? version}); /// large release comes in parts.
Future<PublishedRelease> release(
String publicId, {
int? version,
void Function(int fetched, int total)? onFetched,
});
/// 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);

View file

@ -103,19 +103,25 @@ class ExploreRepository(
/// Copies the latest release of [studyset] into the person's library and /// Copies the latest release of [studyset] into the person's library and
/// returns the copy's top studyset; tells [onProgress] how much is done, /// 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 /// from 0 to 1: fetching the release the first half, copying it the
/// read all of, having written nothing. /// second. Throws [ReleaseUnreadable] for a release this app can't read
/// all of, having written nothing; [InstanceUnreachable] or
/// [InstanceFailed] for one the instance didn't give out.
Future<String> addToLibrary( Future<String> addToLibrary(
ExploreStudyset studyset, { ExploreStudyset studyset, {
void Function(double done)? onProgress, void Function(double done)? onProgress,
}) async { }) async {
final release = await _gateway.release(studyset.publicId); final release = await _gateway.release(
studyset.publicId,
onFetched: (fetched, total) =>
onProgress?.call(total == 0 ? 0.5 : fetched / total / 2),
);
return await _copier.copy( return await _copier.copy(
release, release,
location: studyset.location, location: studyset.location,
fileOf: (sha256) => fileOf: (sha256) =>
_gateway.file(studyset.publicId, release.version, sha256), _gateway.file(studyset.publicId, release.version, sha256),
onProgress: onProgress, onProgress: (done) => onProgress?.call(0.5 + done / 2),
); );
} }
} }

View file

@ -252,10 +252,23 @@ class ReleaseCopier(
for (final (i, card) in cards.indexed) { for (final (i, card) in cards.indexed) {
ids[card.id] = cardIds[i]; ids[card.id] = cardIds[i];
} }
await _tags.addTags({ // Every name at once: each tag read takes in every card's tags.
for (final (i, card) in cards.indexed) final names = {for (final card in cards) ...card.tags};
if (card.tags.isNotEmpty) cardIds[i]: await tagsOf(card.tags), final tagged = await _tags.addTagsNamed(
}); {
for (final (i, card) in cards.indexed)
if (card.tags.isNotEmpty) cardIds[i]: {...card.tags},
},
fromJellyfin: {
for (final name in names)
if (tags[canonicalTagName(name)]?.fromJellyfin ?? false) name,
},
colorNames: {
for (final MapEntry(key: name, value: look) in tags.entries)
name: look.colorName,
},
);
tagIds.addAll(tagged);
done += cards.length; done += cards.length;
onProgress?.call(done / total); onProgress?.call(done / total);

View file

@ -17,32 +17,110 @@ class ServerpodExploreGateway(final protocol.Client _client)
Future<List<ExploreStudyset>> list( Future<List<ExploreStudyset>> list(
ExploreFilter filter, { ExploreFilter filter, {
int offset = 0, int offset = 0,
}) async => [ }) => _asked(
for (final entry in await _client.explore.list( () async => [
protocol.ExploreQuery( for (final entry in await _client.explore.list(
text: filter.text, protocol.ExploreQuery(
frontLanguage: filter.frontLanguage, text: filter.text,
backLanguage: filter.backLanguage, frontLanguage: filter.frontLanguage,
withCards: filter.kinds.contains(ContentKind.cards), backLanguage: filter.backLanguage,
withNotes: filter.kinds.contains(ContentKind.notes), withCards: filter.kinds.contains(ContentKind.cards),
withPaths: filter.kinds.contains(ContentKind.paths), withNotes: filter.kinds.contains(ContentKind.notes),
withPdfs: filter.kinds.contains(ContentKind.pdfs), withPaths: filter.kinds.contains(ContentKind.paths),
offset: offset, withPdfs: filter.kinds.contains(ContentKind.pdfs),
), offset: offset,
)) ),
_studyset(entry), ))
]; _studyset(entry),
],
);
@override @override
Future<List<ExploreStudyset>> entries(List<String> publicIds) async => [ Future<List<ExploreStudyset>> entries(List<String> publicIds) => _asked(
for (final entry in await _client.explore.entries([ () async => [
for (final id in publicIds) protocol.UuidValue.withValidation(id), for (final entry in await _client.explore.entries([
])) for (final id in publicIds) protocol.UuidValue.withValidation(id),
_studyset(entry), ]))
]; _studyset(entry),
],
);
/// As many files as the instance gives out in a part at most
/// (`releasePartMost`).
static const _partSize = 500;
@override @override
Future<PublishedRelease> release(String publicId, {int? version}) async { Future<PublishedRelease> release(
String publicId, {
int? version,
void Function(int fetched, int total)? onFetched,
}) => _asked(() async {
final id = protocol.UuidValue.withValidation(publicId);
final protocol.ReleasePart first;
try {
first = await _part(id, version, 0);
} on protocol.ServerpodClientBadRequest {
// An instance from before parts (v0.17) gives a release whole.
return await _whole(publicId, version);
}
final items = <String, Map<String, Object?>>{};
void take(protocol.ReleasePart part) {
for (final item in part.items) {
if (jsonDecode(item.json) case final Map<String, Object?> json) {
items[item.path] = json;
}
}
}
take(first);
var fetched = first.items.length;
onFetched?.call(fetched, first.total);
while (fetched < first.total) {
// The version the first part named: a release published meanwhile
// isn't mixed in.
final part = await _part(id, first.version, fetched);
if (part.items.isEmpty) {
break;
}
take(part);
fetched += part.items.length;
onFetched?.call(fetched, first.total);
}
return PublishedRelease(
publicId: publicId,
version: first.version,
items: items,
files: first.files,
);
});
/// The part of a release from [offset], asked for again, twice at most,
/// when the connection drops: only that part starts over.
Future<protocol.ReleasePart> _part(
protocol.UuidValue id,
int? version,
int offset,
) async {
for (var attempt = 1; ; attempt++) {
try {
return await _client.explore.releasePart(
id,
version: version,
offset: offset,
count: _partSize,
);
} on protocol.ServerpodClientNetworkException {
if (attempt == 3) {
rethrow;
}
await Future<void>.delayed(Duration(milliseconds: 500 * attempt));
}
}
}
/// Release [version] of [publicId] in one piece, as instances before
/// parts give it out.
Future<PublishedRelease> _whole(String publicId, int? version) async {
final release = await _client.explore.release( final release = await _client.explore.release(
protocol.UuidValue.withValidation(publicId), protocol.UuidValue.withValidation(publicId),
version: version, version: version,
@ -60,14 +138,30 @@ class ServerpodExploreGateway(final protocol.Client _client)
} }
@override @override
Future<Uint8List> file(String publicId, int version, String sha256) async => Future<Uint8List> file(String publicId, int version, String sha256) => _asked(
Uint8List.sublistView( () async => Uint8List.sublistView(
await _client.explore.file( await _client.explore.file(
protocol.UuidValue.withValidation(publicId), protocol.UuidValue.withValidation(publicId),
version, version,
sha256, sha256,
), ),
); ),
);
/// What [ask] answers; what failed as [InstanceUnreachable] or
/// [InstanceFailed].
static Future<T> _asked<T>(Future<T> Function() ask) async {
try {
return await ask();
} on protocol.ServerpodClientNetworkException {
throw const InstanceUnreachable();
} on protocol.ServerpodClientException {
throw const InstanceFailed();
} on protocol.SerializableException {
// The instance's own refusal: a release gone, say.
throw const InstanceFailed();
}
}
ExploreStudyset _studyset(protocol.ExploreEntry entry) => ExploreStudyset( ExploreStudyset _studyset(protocol.ExploreEntry entry) => ExploreStudyset(
publicId: entry.publicId.uuid, publicId: entry.publicId.uuid,

View file

@ -107,10 +107,18 @@ class _ExploreState() extends State<_Explore> {
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(l10n.exploreNeedsNewerApp)), SnackBar(content: Text(l10n.exploreNeedsNewerApp)),
); );
case ExploreFailed(): case ExploreNotReached():
messenger.showSnackBar( messenger.showSnackBar(
SnackBar(content: Text(l10n.exploreFailed)), SnackBar(content: Text(l10n.exploreFailed)),
); );
case ExploreNotGivenOut():
messenger.showSnackBar(
SnackBar(content: Text(l10n.exploreNotGivenOut)),
);
case ExploreNotWritten():
messenger.showSnackBar(
SnackBar(content: Text(l10n.exploreNotWritten)),
);
case null: case null:
break; break;
} }

View file

@ -182,7 +182,8 @@ class TagRepository(
} }
/// Gives each card the tags [tagNamesByCard] names, making those missing /// Gives each card the tags [tagNamesByCard] names, making those missing
/// (as made for Jellyfin when [fromJellyfin] names them), with one read of /// (as made for Jellyfin when [fromJellyfin] names them, in the colour
/// [colorNames] gives their name in any case), with one read of
/// the tags rather than one per name: a word added from the dictionary /// the tags rather than one per name: a word added from the dictionary
/// gets four at once, and every read takes in each card's tags. A name in /// gets four at once, and every read takes in each card's tags. A name in
/// any case is the tag it names. Returns each tag's id by its name as /// any case is the tag it names. Returns each tag's id by its name as
@ -190,6 +191,7 @@ class TagRepository(
Future<Map<String, String>> addTagsNamed( Future<Map<String, String>> addTagsNamed(
Map<String, Set<String>> tagNamesByCard, { Map<String, Set<String>> tagNamesByCard, {
Set<String> fromJellyfin = const {}, Set<String> fromJellyfin = const {},
Map<String, String> colorNames = const {},
}) async { }) async {
final names = {for (final names in tagNamesByCard.values) ...names}; final names = {for (final names in tagNamesByCard.values) ...names};
if (names.any((name) => tidyTagName(name).isEmpty)) { if (names.any((name) => tidyTagName(name).isEmpty)) {
@ -205,7 +207,8 @@ class TagRepository(
(made[canonicalTagName(name)] ??= await _gateway.insertTag( (made[canonicalTagName(name)] ??= await _gateway.insertTag(
encodeTagPayload(( encodeTagPayload((
name: name, name: name,
colorName: defaultTagColorName, colorName:
colorNames[canonicalTagName(name)] ?? defaultTagColorName,
), fromJellyfin: fromJellyfin.contains(name)), ), fromJellyfin: fromJellyfin.contains(name)),
)); ));
} }

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_flutter/src/core/router/route_paths.dart';
import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.dart'; import 'package:halkyon_learn_flutter/src/feature/auth/model/auth_status.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';
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/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart'; import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart'; import 'package:halkyon_learn_flutter/src/feature/profile/model/public_profile.dart';
import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart'; import 'package:halkyon_learn_flutter/src/feature/studyset/data/studyset_payload.dart';
@ -17,6 +18,7 @@ import 'package:halkyon_learn_flutter/src/feature/studyset/widget/studyset_scree
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
import '../../support/fake_auth_gateway.dart'; import '../../support/fake_auth_gateway.dart';
import '../../support/fake_card_gateway.dart';
import '../../support/fake_explore_gateway.dart'; import '../../support/fake_explore_gateway.dart';
import '../../support/fake_profile_gateway.dart'; import '../../support/fake_profile_gateway.dart';
import '../../support/fake_studyset_gateway.dart'; import '../../support/fake_studyset_gateway.dart';
@ -94,6 +96,7 @@ void main() {
FakeExploreGateway explore, { FakeExploreGateway explore, {
bool identified = true, bool identified = true,
FakeProfileGateway? profiles, FakeProfileGateway? profiles,
FakeCardGateway? cards,
}) async { }) async {
tester.view.physicalSize = const Size(900, 1400); tester.view.physicalSize = const Size(900, 1400);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;
@ -110,6 +113,7 @@ void main() {
studysetGateway: studysets, studysetGateway: studysets,
exploreGateway: explore, exploreGateway: explore,
profileGateway: profiles, profileGateway: profiles,
cardGateway: cards,
), ),
location: RoutePaths.explore, location: RoutePaths.explore,
); );
@ -395,6 +399,55 @@ void main() {
expect(await StudysetRepository(studysets).all(), isEmpty); expect(await StudysetRepository(studysets).all(), isEmpty);
}); });
group('says what kept a studyset from being added', () {
Future<void> add(
WidgetTester tester, {
Exception? failRelease,
FakeCardGateway? cards,
}) async {
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese')],
releases: [release('japanese', 'Japanese')],
)..failRelease = failRelease,
cards: cards,
);
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
}
testWidgets('no connection, or no answer in time', (tester) async {
await add(tester, failRelease: const InstanceUnreachable());
expect(
find.text("Couldn't reach your instance. Try again."),
findsOneWidget,
);
});
testWidgets("the instance's own error", (tester) async {
await add(tester, failRelease: const InstanceFailed());
expect(
find.text(
"Your instance couldn't give out this studyset. Try again later.",
),
findsOneWidget,
);
});
testWidgets('this device, writing it', (tester) async {
await add(
tester,
cards: FakeCardGateway()..failWrites = Exception('full'),
);
expect(
find.text("Couldn't add it to your library on this device."),
findsOneWidget,
);
expect(find.textContaining('reach'), findsNothing);
});
});
testWidgets('without the instance, it says so and tries again', ( testWidgets('without the instance, it says so and tries again', (
tester, tester,
) async { ) async {

View file

@ -72,14 +72,16 @@ void main() {
late TagRepository tags; late TagRepository tags;
late AssetRepository assets; late AssetRepository assets;
late ReleaseCopier copier; late ReleaseCopier copier;
late FakeTagGateway tagGateway;
setUp(() { setUp(() {
studysets = StudysetRepository(FakeStudysetGateway()); studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway()); cards = CardRepository(FakeCardGateway());
notes = NoteRepository(FakeNoteGateway()); notes = NoteRepository(FakeNoteGateway());
paths = LearningPathRepository(FakeLearningPathGateway()); paths = LearningPathRepository(FakeLearningPathGateway());
tagGateway = FakeTagGateway();
tags = TagRepository( tags = TagRepository(
FakeTagGateway(), tagGateway,
cardChanges: cards.changes, cardChanges: cards.changes,
noteChanges: notes.changes, noteChanges: notes.changes,
pathChanges: paths.changes, pathChanges: paths.changes,
@ -322,6 +324,46 @@ void main() {
expect(tagging.of(copied.last.id).map((tag) => tag.id), contains(noun)); expect(tagging.of(copied.last.id).map((tag) => tag.id), contains(noun));
}); });
test("a release's tags are written with one read of the tags for each "
'studyset, not one for each tag: a language has dozens, and each read '
"takes in every card's tags", () async {
final release = PublishedRelease(
publicId: 'publication-1',
version: 1,
items: {
'studyset.json': item(
'root',
encodeStudysetPayload(StudysetContent.titled('Words')),
),
for (var index = 0; index < 40; index++)
'cards/card-$index.json': item(
'card-$index',
encodeCardPayload(card('$index', 'number $index')),
position: 'a${index.toString().padLeft(2, '0')}',
tags: ['Tag $index', 'Numbers'],
),
},
);
final before = tagGateway.cardTagReads;
final id = await copier.copy(
release,
location: 'halkyon.eu/@alice/words',
fileOf: fileOf,
);
expect(tagGateway.cardTagReads - before, lessThanOrEqualTo(4));
final tagging = await tags.read();
final copied = await cards.cardsOf(id);
expect(
{
for (final card in copied)
for (final tag in tagging.of(card.id)) tag.name,
},
hasLength(41),
);
});
test("the files its content names become the person's own", () async { test("the files its content names become the person's own", () async {
final id = await copier.copy( final id = await copier.copy(
japanese(), japanese(),

View file

@ -0,0 +1,131 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_client/halkyon_learn_client.dart' as protocol;
import 'package:halkyon_learn_flutter/src/feature/explore/data/explore_gateway.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/serverpod_explore_gateway.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
const publicId = '01a0fc5b-0168-775a-aca8-9a36cdb471a4';
/// Release 2 of [publicId]: [count] JSON files, `cards/0000.json` on.
List<protocol.ReleaseItem> items(int count) => [
for (var index = 0; index < count; index++)
protocol.ReleaseItem(
path: 'cards/${index.toString().padLeft(4, '0')}.json',
json: jsonEncode({'id': 'card-$index'}),
),
];
void main() {
/// What the instance was asked, by method and arguments, in order.
late List<(String, Map<String, Object?>)> asked;
ServerpodExploreGateway gateway(
Future<http.Response> Function(String method, Map<String, Object?> args)
answer,
) {
asked = [];
final client = protocol.Client(
'https://learn.example.org/',
httpClientOverride: MockClient((request) {
final method = request.url.pathSegments.last;
final args = jsonDecode(request.body) as Map<String, Object?>;
asked.add((method, args));
return answer(method, args);
}),
);
addTearDown(client.close);
return ServerpodExploreGateway(client);
}
http.Response ok(Object answer) =>
http.Response(protocol.SerializationManager.encode(answer), 200);
/// The parts of a release of [total] files, [size] to a part.
http.Response partOf(Map<String, Object?> args, int total, int size) {
final offset = args['offset']! as int;
return ok(
protocol.ReleasePart(
publicId: protocol.UuidValue.withValidation(publicId),
version: 2,
total: total,
items: items(total).skip(offset).take(size).toList(),
files: ['a' * 64],
),
);
}
test('a release comes in parts, each from where the last ended and of '
'the version the first named, saying how far it has come', () async {
final fetched = <(int, int)>[];
final release = await gateway(
(method, args) async => partOf(args, 1200, 500),
).release(publicId, onFetched: (done, total) => fetched.add((done, total)));
expect(release.version, 2);
expect(release.items, hasLength(1200));
expect(release.items['cards/1199.json'], {'id': 'card-1199'});
expect(release.files, ['a' * 64]);
expect([for (final (_, args) in asked) args['offset']], [0, 500, 1000]);
expect([for (final (_, args) in asked) args['version']], [null, 2, 2]);
expect(fetched, [(500, 1200), (1000, 1200), (1200, 1200)]);
});
test('a part whose connection drops is asked for again, alone', () async {
var drops = 1;
final release = await gateway((method, args) async {
if (args['offset'] == 500 && drops-- > 0) {
throw const SocketException('Connection reset by peer');
}
return partOf(args, 900, 500);
}).release(publicId);
expect(release.items, hasLength(900));
expect([for (final (_, args) in asked) args['offset']], [0, 500, 500]);
});
test('an instance from before parts gives the release whole', () async {
final release = await gateway(
(method, args) async => switch (method) {
'releasePart' => http.Response('Method not found in endpoint', 400),
_ => ok(
protocol.ReleaseContent(
publicId: protocol.UuidValue.withValidation(publicId),
version: 1,
items: items(3),
files: const [],
),
),
},
).release(publicId);
expect(release.version, 1);
expect(release.items, hasLength(3));
expect(
[for (final (method, _) in asked) method],
[
'releasePart',
'release',
],
);
});
test('says whether the instance could not be reached or answered with an '
'error', () async {
await expectLater(
gateway(
(method, args) async => throw const SocketException('No route'),
).release(publicId),
throwsA(isA<InstanceUnreachable>()),
);
await expectLater(
gateway(
(method, args) async => http.Response('Internal server error', 500),
).release(publicId),
throwsA(isA<InstanceFailed>()),
);
});
}

View file

@ -21,6 +21,9 @@ class FakeExploreGateway({
/// When set, every request fails with it. /// When set, every request fails with it.
Exception? fail; Exception? fail;
/// When set, giving out a release fails with it, as from the instance.
Exception? failRelease;
@override @override
Future<List<ExploreStudyset>> list( Future<List<ExploreStudyset>> list(
ExploreFilter filter, { ExploreFilter filter, {
@ -63,17 +66,24 @@ class FakeExploreGateway({
} }
@override @override
Future<PublishedRelease> release(String publicId, {int? version}) async { Future<PublishedRelease> release(
if (fail case final error?) { String publicId, {
int? version,
void Function(int fetched, int total)? onFetched,
}) async {
if (failRelease ?? fail case final error?) {
throw error; throw error;
} }
final of = [ final of = [
for (final release in releases) for (final release in releases)
if (release.publicId == publicId) release, if (release.publicId == publicId) release,
]..sort((a, b) => b.version.compareTo(a.version)); ]..sort((a, b) => b.version.compareTo(a.version));
return version == null final release = version == null
? of.first ? of.first
: of.firstWhere((release) => release.version == version); : of.firstWhere((release) => release.version == version);
// In one part.
onFetched?.call(release.items.length, release.items.length);
return release;
} }
@override @override

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