feat(explore): your own studyset is yours, not one to add

Explore offered Add on the author's own published studyset and made a
whole second copy of it beside the original. The instance now names
the studyset an entry was published from to its author alone
(`yourStudysetId`, never to anyone else), and Explore shows it as Yours,
opening the original, while it is in the library. The summary still
adds a copy anyway, to see it as learners get it.

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 22:34:28 +02:00
commit d3ece5c528
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 263 additions and 17 deletions

File diff suppressed because one or more lines are too long

View file

@ -41,6 +41,7 @@ abstract class ExploreEntry
required this.pathCount, required this.pathCount,
required this.pdfCount, required this.pdfCount,
this.learners, this.learners,
this.yourStudysetId,
}); });
factory ExploreEntry({ factory ExploreEntry({
@ -68,6 +69,7 @@ abstract class ExploreEntry
required int pathCount, required int pathCount,
required int pdfCount, required int pdfCount,
int? learners, int? learners,
_isc.UuidValue? yourStudysetId,
}) = _ExploreEntryImpl; }) = _ExploreEntryImpl;
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) { factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
@ -100,6 +102,11 @@ abstract class ExploreEntry
pathCount: jsonSerialization['pathCount'] as int, pathCount: jsonSerialization['pathCount'] as int,
pdfCount: jsonSerialization['pdfCount'] as int, pdfCount: jsonSerialization['pdfCount'] as int,
learners: jsonSerialization['learners'] as int?, learners: jsonSerialization['learners'] as int?,
yourStudysetId: jsonSerialization['yourStudysetId'] == null
? null
: _isc.UuidValueJsonExtension.fromJson(
jsonSerialization['yourStudysetId'],
),
); );
} }
@ -163,6 +170,11 @@ abstract class ExploreEntry
/// null for fewer than 5, so that nobody can be told apart. /// null for fewer than 5, so that nobody can be told apart.
int? learners; int? learners;
/// For its author asking, their studyset it was published from (decided
/// 2026-10-16: Explore opens it rather than adding a copy of it); null for
/// everyone else.
_isc.UuidValue? yourStudysetId;
/// Returns a shallow copy of this [ExploreEntry] /// Returns a shallow copy of this [ExploreEntry]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_isc.useResult @_isc.useResult
@ -191,6 +203,7 @@ abstract class ExploreEntry
int? pathCount, int? pathCount,
int? pdfCount, int? pdfCount,
int? learners, int? learners,
_isc.UuidValue? yourStudysetId,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -220,6 +233,7 @@ abstract class ExploreEntry
'pathCount': pathCount, 'pathCount': pathCount,
'pdfCount': pdfCount, 'pdfCount': pdfCount,
if (learners != null) 'learners': learners, if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
}; };
} }
@ -251,6 +265,7 @@ abstract class ExploreEntry
'pathCount': pathCount, 'pathCount': pathCount,
'pdfCount': pdfCount, 'pdfCount': pdfCount,
if (learners != null) 'learners': learners, if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
}; };
} }
@ -288,6 +303,7 @@ class _ExploreEntryImpl extends ExploreEntry {
required int pathCount, required int pathCount,
required int pdfCount, required int pdfCount,
int? learners, int? learners,
_isc.UuidValue? yourStudysetId,
}) : super._( }) : super._(
publicId: publicId, publicId: publicId,
location: location, location: location,
@ -313,6 +329,7 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount, pathCount: pathCount,
pdfCount: pdfCount, pdfCount: pdfCount,
learners: learners, learners: learners,
yourStudysetId: yourStudysetId,
); );
/// Returns a shallow copy of this [ExploreEntry] /// Returns a shallow copy of this [ExploreEntry]
@ -344,6 +361,7 @@ class _ExploreEntryImpl extends ExploreEntry {
int? pathCount, int? pathCount,
int? pdfCount, int? pdfCount,
Object? learners = _Undefined, Object? learners = _Undefined,
Object? yourStudysetId = _Undefined,
}) { }) {
return ExploreEntry( return ExploreEntry(
publicId: publicId ?? this.publicId, publicId: publicId ?? this.publicId,
@ -376,6 +394,9 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount ?? this.pathCount, pathCount: pathCount ?? this.pathCount,
pdfCount: pdfCount ?? this.pdfCount, pdfCount: pdfCount ?? this.pdfCount,
learners: learners is int? ? learners : this.learners, learners: learners is int? ? learners : this.learners,
yourStudysetId: yourStudysetId is _isc.UuidValue?
? yourStudysetId
: this.yourStudysetId,
); );
} }
} }

View file

@ -1697,6 +1697,8 @@
"exploreAdd": "Hinzufügen", "exploreAdd": "Hinzufügen",
"exploreInLibraryShort": "In Bibliothek", "exploreInLibraryShort": "In Bibliothek",
"exploreInLibrary": "In deiner Bibliothek", "exploreInLibrary": "In deiner Bibliothek",
"exploreYours": "Deins",
"exploreAddCopyAnyway": "Trotzdem eine Kopie hinzufügen",
"explorePercent": "{percent} %", "explorePercent": "{percent} %",
"exploreAdded": "Zu deiner Bibliothek hinzugefügt.", "exploreAdded": "Zu deiner Bibliothek hinzugefügt.",
"exploreOpen": "Öffnen", "exploreOpen": "Öffnen",

View file

@ -1997,6 +1997,8 @@
"exploreAdd": "Add", "exploreAdd": "Add",
"exploreInLibraryShort": "In library", "exploreInLibraryShort": "In library",
"exploreInLibrary": "In your library", "exploreInLibrary": "In your library",
"exploreYours": "Yours",
"exploreAddCopyAnyway": "Add a copy anyway",
"explorePercent": "{percent} %", "explorePercent": "{percent} %",
"@explorePercent": {"placeholders": {"percent": {"type": "int"}}}, "@explorePercent": {"placeholders": {"percent": {"type": "int"}}},
"exploreAdded": "Added to your library.", "exploreAdded": "Added to your library.",

View file

@ -10047,6 +10047,18 @@ abstract class AppLocalizations {
/// **'In your library'** /// **'In your library'**
String get exploreInLibrary; String get exploreInLibrary;
/// No description provided for @exploreYours.
///
/// In en, this message translates to:
/// **'Yours'**
String get exploreYours;
/// No description provided for @exploreAddCopyAnyway.
///
/// In en, this message translates to:
/// **'Add a copy anyway'**
String get exploreAddCopyAnyway;
/// No description provided for @explorePercent. /// No description provided for @explorePercent.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -6333,6 +6333,12 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get exploreInLibrary => 'In deiner Bibliothek'; String get exploreInLibrary => 'In deiner Bibliothek';
@override
String get exploreYours => 'Deins';
@override
String get exploreAddCopyAnyway => 'Trotzdem eine Kopie hinzufügen';
@override @override
String explorePercent(int percent) { String explorePercent(int percent) {
return '$percent %'; return '$percent %';

View file

@ -6276,6 +6276,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get exploreInLibrary => 'In your library'; String get exploreInLibrary => 'In your library';
@override
String get exploreYours => 'Yours';
@override
String get exploreAddCopyAnyway => 'Add a copy anyway';
@override @override
String explorePercent(int percent) { String explorePercent(int percent) {
return '$percent %'; return '$percent %';

View file

@ -35,8 +35,9 @@ class ExploreBloc(final ExploreRepository _explore)
), ),
); );
try { try {
final (copies, (:page, :hasMore)) = await ( final (copies, held, (:page, :hasMore)) = await (
_explore.copies(), _explore.copies(),
_explore.held(),
_explore.list(event.filter), _explore.list(event.filter),
).wait; ).wait;
if (query != _query) { if (query != _query) {
@ -48,6 +49,7 @@ class ExploreBloc(final ExploreRepository _explore)
studysets: page, studysets: page,
hasMore: hasMore, hasMore: hasMore,
copies: copies, copies: copies,
held: held,
), ),
); );
} on Object catch (error) { } on Object catch (error) {
@ -178,7 +180,9 @@ 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
/// public id, the copy's top studyset), which are being added and how far /// public id, the copy's top studyset), which studysets are in their
/// library ([held]: one of theirs Explore lists opens rather than being
/// added again), which are being added and how far
/// ([adding], null before the first step), and what the last request came /// ([adding], null before the first step), and what the last request came
/// to ([outcome], cleared by the next state). /// to ([outcome], cleared by the next state).
final class const ExploreState({ final class const ExploreState({
@ -188,9 +192,17 @@ final class const ExploreState({
final bool hasMore = false, final bool hasMore = false,
final bool loadingMore = false, final bool loadingMore = false,
final Map<String, String> copies = const {}, final Map<String, String> copies = const {},
final Set<String> held = const {},
final Map<String, double?> adding = const {}, final Map<String, double?> adding = const {},
final ExploreOutcome? outcome, final ExploreOutcome? outcome,
}) with Equatable { }) with Equatable {
/// The person's own studyset [studyset] was published from, while it is
/// in their library; null for anyone else's.
String? yoursOf(ExploreStudyset studyset) => switch (studyset.yours) {
final id? when held.contains(id) => id,
_ => null,
};
/// This state with what is given changed, and no outcome unless given. /// This state with what is given changed, and no outcome unless given.
ExploreState copyWith({ ExploreState copyWith({
ExploreStatus? status, ExploreStatus? status,
@ -199,6 +211,7 @@ final class const ExploreState({
bool? hasMore, bool? hasMore,
bool? loadingMore, bool? loadingMore,
Map<String, String>? copies, Map<String, String>? copies,
Set<String>? held,
Map<String, double?>? adding, Map<String, double?>? adding,
ExploreOutcome? outcome, ExploreOutcome? outcome,
}) => ExploreState( }) => ExploreState(
@ -208,6 +221,7 @@ final class const ExploreState({
hasMore: hasMore ?? this.hasMore, hasMore: hasMore ?? this.hasMore,
loadingMore: loadingMore ?? this.loadingMore, loadingMore: loadingMore ?? this.loadingMore,
copies: copies ?? this.copies, copies: copies ?? this.copies,
held: held ?? this.held,
adding: adding ?? this.adding, adding: adding ?? this.adding,
outcome: outcome, outcome: outcome,
); );
@ -220,6 +234,7 @@ final class const ExploreState({
hasMore, hasMore,
loadingMore, loadingMore,
copies, copies,
held,
adding, adding,
outcome, outcome,
]; ];

View file

@ -46,6 +46,12 @@ class ExploreRepository(
publication: studyset.id, publication: studyset.id,
}; };
/// The ids of the person's studysets in their library, those deleted
/// left out: whether one Explore names as theirs is still there.
Future<Set<String>> held() async => {
for (final studyset in await _studysets.all()) studyset.id,
};
/// What the latest release of the published studyset the person's copy /// What the latest release of the published studyset the person's copy
/// [rootId] (its top studyset) came from would bring it; null while the /// [rootId] (its top studyset) came from would bring it; null while the
/// copy holds the latest, and for a studyset its author withdrew, whose /// copy holds the latest, and for a studyset its author withdrew, whose

View file

@ -188,6 +188,7 @@ class ServerpodExploreGateway(final protocol.Client _client)
paths: entry.pathCount, paths: entry.pathCount,
pdfs: entry.pdfCount, pdfs: entry.pdfCount,
learners: entry.learners, learners: entry.learners,
yours: entry.yourStudysetId?.uuid,
); );
/// A description as the instance keeps it, its rich text's JSON; empty /// A description as the instance keeps it, its rich text's JSON; empty

View file

@ -52,6 +52,11 @@ final class const ExploreStudyset({
/// How many people learn it here, rounded (5, 10, 20, 50, …); null for /// How many people learn it here, rounded (5, 10, 20, 50, …); null for
/// fewer than 5 (ADR 0005: shown, never ranked). /// fewer than 5 (ADR 0005: shown, never ranked).
final int? learners, final int? learners,
/// For its author, their studyset it was published from (decided
/// 2026-10-16: Explore opens it rather than adding a copy); null for
/// everyone else, and from instances before v0.18.
final String? yours,
}) with Equatable { }) with Equatable {
/// Its author as they are shown: their display name, else their handle. /// Its author as they are shown: their display name, else their handle.
String get author => displayName.isEmpty ? '@$handle' : displayName; String get author => displayName.isEmpty ? '@$handle' : displayName;
@ -82,6 +87,7 @@ final class const ExploreStudyset({
paths, paths,
pdfs, pdfs,
learners, learners,
yours,
]; ];
} }

View file

@ -626,9 +626,11 @@ class const LicenseChip({required final String license, super.key})
} }
/// Add to library for [studyset]: its progress while it is being added, In /// Add to library for [studyset]: its progress while it is being added, In
/// your library (opening the copy) once there is a copy. [wide] in the /// your library (opening the copy) once there is a copy, and Yours (opening
/// summary sheet, which either closes; [compact] on a card, as Add and In /// it) for the person's own (decided 2026-10-16), the summary also adding a
/// library, leaving the card's line its room. /// copy anyway, to see it as learners get it. [wide] in the summary sheet,
/// which either closes; [compact] on a card, as Add and In library, leaving
/// the card's line its room.
class const ExploreAddButton({ class const ExploreAddButton({
required final ExploreStudyset studyset, required final ExploreStudyset studyset,
required final ExploreState state, required final ExploreState state,
@ -647,6 +649,48 @@ class const ExploreAddButton({
padding: const EdgeInsets.symmetric(horizontal: 12), padding: const EdgeInsets.symmetric(horizontal: 12),
) )
: null; : null;
void add() {
final bloc = context.read<ExploreBloc>();
// From the summary, it goes on in the list: its progress shows on
// the studyset's card, and what came of it below the list.
if (wide) {
Navigator.of(context).pop();
}
bloc.add(ExploreEventAddRequested(studyset));
}
if (state.yoursOf(studyset) case final own?) {
final open = TextButton.icon(
style: wide
? TextButton.styleFrom(minimumSize: const Size.fromHeight(48))
: compact
? TextButton.styleFrom(visualDensity: VisualDensity.compact)
: null,
onPressed: () {
final router = GoRouter.of(context);
if (wide) {
Navigator.of(context).pop();
}
router.go(RoutePaths.studyset(own));
},
icon: const Icon(LucideIcons.user),
label: Text(l10n.exploreYours),
);
if (!wide || state.copies.containsKey(studyset.publicId)) {
return open;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
open,
if (state.adding.containsKey(studyset.publicId))
const Center(child: ButtonLoadingIndicator())
else
TextButton(onPressed: add, child: Text(l10n.exploreAddCopyAnyway)),
],
);
}
if (state.copies[studyset.publicId] case final copy?) { if (state.copies[studyset.publicId] case final copy?) {
return TextButton.icon( return TextButton.icon(
style: wide style: wide
@ -679,15 +723,7 @@ class const ExploreAddButton({
} }
return FilledButton.tonalIcon( return FilledButton.tonalIcon(
style: style, style: style,
onPressed: () { onPressed: add,
final bloc = context.read<ExploreBloc>();
// From the summary, it goes on in the list: its progress shows on
// the studyset's card, and what came of it below the list.
if (wide) {
Navigator.of(context).pop();
}
bloc.add(ExploreEventAddRequested(studyset));
},
icon: const Icon(LucideIcons.plus), icon: const Icon(LucideIcons.plus),
label: Text(compact ? l10n.exploreAdd : l10n.exploreAddToLibrary), label: Text(compact ? l10n.exploreAdd : l10n.exploreAddToLibrary),
); );

View file

@ -36,6 +36,7 @@ ExploreStudyset published(
int paths = 0, int paths = 0,
int? learners, int? learners,
String? basedOn, String? basedOn,
String? yours,
}) => ExploreStudyset( }) => ExploreStudyset(
publicId: publicId, publicId: publicId,
location: 'halkyon.eu/@$handle/$publicId', location: 'halkyon.eu/@$handle/$publicId',
@ -53,6 +54,7 @@ ExploreStudyset published(
paths: paths, paths: paths,
learners: learners, learners: learners,
basedOn: basedOn, basedOn: basedOn,
yours: yours,
); );
/// The JSON a synced [payload] holds. /// The JSON a synced [payload] holds.
@ -366,6 +368,64 @@ void main() {
expect(find.text('Add'), findsNothing); expect(find.text('Add'), findsNothing);
}); });
group("the person's own studyset", () {
setUp(
() => studysets.receiveRemoteChange(
'mine',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
),
);
testWidgets('shows as theirs and opens, adding no copy; the summary adds '
'a copy anyway when asked', (tester) async {
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese', yours: 'mine')],
releases: [release('japanese', 'Japanese')],
),
);
expect(find.text('Add'), findsNothing);
await tester.tap(find.text('Yours'));
await tester.pumpAndSettle();
expect(find.byType(StudysetScreen), findsOneWidget);
expect(await StudysetRepository(studysets).all(), hasLength(1));
await openTab(tester, 'Explore');
await tester.tap(find.text('Japanese').first);
await tester.pumpAndSettle();
await tester.tap(find.text('Add a copy anyway'));
await tester.pumpAndSettle();
final all = await StudysetRepository(studysets).all();
expect(all, hasLength(2));
expect(
all.where((studyset) => studyset.content.publishedAt != null),
hasLength(1),
);
});
testWidgets('deleted from the library, it can be added again', (
tester,
) async {
studysets.receiveRemoteChange(
'mine',
encodeStudysetPayload(StudysetContent.titled('Japanese')),
deletedAt: DateTime.utc(2026, 10, 15),
);
await showExplore(
tester,
FakeExploreGateway(
published: [published('Japanese', yours: 'mine')],
releases: [release('japanese', 'Japanese')],
),
);
expect(find.text('Yours'), findsNothing);
expect(find.text('Add'), findsOneWidget);
});
});
testWidgets('a release in a schema this app does not know is not added, ' testWidgets('a release in a schema this app does not know is not added, '
'and says why', (tester) async { 'and says why', (tester) async {
final newer = release('japanese', 'Japanese'); final newer = release('japanese', 'Japanese');

View file

@ -8,6 +8,7 @@ import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/profile/profile_rules.dart'; import 'package:halkyon_learn_server/src/profile/profile_rules.dart';
import 'package:halkyon_learn_server/src/publish/release_repository.dart'; import 'package:halkyon_learn_server/src/publish/release_repository.dart';
import 'package:serverpod/serverpod.dart'; import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// How many studysets a page of Explore lists. /// How many studysets a page of Explore lists.
const explorePageSize = 30; const explorePageSize = 30;
@ -263,7 +264,10 @@ class ExploreEndpoint extends Endpoint {
final rows = await session.db.unsafeQuery( final rows = await session.db.unsafeQuery(
'SELECT * FROM (' 'SELECT * FROM ('
'SELECT DISTINCT ON (r."publishedStudysetId") r.*, p."publicId", ' 'SELECT DISTINCT ON (r."publishedStudysetId") r.*, p."publicId", '
'p."slug", p."learners", f."handle", f."displayName", f."avatarHash" ' 'p."slug", p."learners", f."handle", f."displayName", f."avatarHash", '
// The author's own studyset, for the author alone.
'CASE WHEN p."authUserId"::text = @me THEN p."studysetId"::text '
'END AS "yourStudysetId" '
'FROM "studyset_release" r ' 'FROM "studyset_release" r '
'JOIN "published_studyset" p ON p."id" = r."publishedStudysetId" ' 'JOIN "published_studyset" p ON p."id" = r."publishedStudysetId" '
'JOIN "public_profile" f ON f."authUserId" = p."authUserId" ' 'JOIN "public_profile" f ON f."authUserId" = p."authUserId" '
@ -273,7 +277,10 @@ class ExploreEndpoint extends Endpoint {
'${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')} '}' '${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')} '}'
'ORDER BY latest."releasedAt" DESC, latest."id" DESC' 'ORDER BY latest."releasedAt" DESC, latest."id" DESC'
'${paged ? ' LIMIT @limit OFFSET @offset' : ''}', '${paged ? ' LIMIT @limit OFFSET @offset' : ''}',
parameters: QueryParameters.named(parameters), parameters: QueryParameters.named({
...parameters,
'me': session.authenticated!.authUserId.uuid,
}),
); );
final domain = accountDomain(session.serverpod); final domain = accountDomain(session.serverpod);
return [ return [
@ -310,6 +317,10 @@ class ExploreEndpoint extends Endpoint {
pathCount: latest['pathCount'] as int, pathCount: latest['pathCount'] as int,
pdfCount: latest['pdfCount'] as int, pdfCount: latest['pdfCount'] as int,
learners: learnersShown(latest['learners'] as int), learners: learnersShown(latest['learners'] as int),
yourStudysetId: switch (latest['yourStudysetId']) {
final String id => UuidValue.withValidation(id),
_ => null,
},
), ),
]; ];
} }

View file

@ -38,3 +38,7 @@ fields:
### How many people learn it here, rounded down (5, 10, 20, 50, 100, …); ### How many people learn it here, rounded down (5, 10, 20, 50, 100, …);
### null for fewer than 5, so that nobody can be told apart. ### null for fewer than 5, so that nobody can be told apart.
learners: int? learners: int?
### For its author asking, their studyset it was published from (decided
### 2026-10-16: Explore opens it rather than adding a copy of it); null for
### everyone else.
yourStudysetId: UuidValue?

View file

@ -41,6 +41,7 @@ abstract class ExploreEntry
required this.pathCount, required this.pathCount,
required this.pdfCount, required this.pdfCount,
this.learners, this.learners,
this.yourStudysetId,
}); });
factory ExploreEntry({ factory ExploreEntry({
@ -68,6 +69,7 @@ abstract class ExploreEntry
required int pathCount, required int pathCount,
required int pdfCount, required int pdfCount,
int? learners, int? learners,
_is.UuidValue? yourStudysetId,
}) = _ExploreEntryImpl; }) = _ExploreEntryImpl;
factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) { factory ExploreEntry.fromJson(Map<String, dynamic> jsonSerialization) {
@ -100,6 +102,11 @@ abstract class ExploreEntry
pathCount: jsonSerialization['pathCount'] as int, pathCount: jsonSerialization['pathCount'] as int,
pdfCount: jsonSerialization['pdfCount'] as int, pdfCount: jsonSerialization['pdfCount'] as int,
learners: jsonSerialization['learners'] as int?, learners: jsonSerialization['learners'] as int?,
yourStudysetId: jsonSerialization['yourStudysetId'] == null
? null
: _is.UuidValueJsonExtension.fromJson(
jsonSerialization['yourStudysetId'],
),
); );
} }
@ -163,6 +170,11 @@ abstract class ExploreEntry
/// null for fewer than 5, so that nobody can be told apart. /// null for fewer than 5, so that nobody can be told apart.
int? learners; int? learners;
/// For its author asking, their studyset it was published from (decided
/// 2026-10-16: Explore opens it rather than adding a copy of it); null for
/// everyone else.
_is.UuidValue? yourStudysetId;
/// Returns a shallow copy of this [ExploreEntry] /// Returns a shallow copy of this [ExploreEntry]
/// with some or all fields replaced by the given arguments. /// with some or all fields replaced by the given arguments.
@_is.useResult @_is.useResult
@ -191,6 +203,7 @@ abstract class ExploreEntry
int? pathCount, int? pathCount,
int? pdfCount, int? pdfCount,
int? learners, int? learners,
_is.UuidValue? yourStudysetId,
}); });
@override @override
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -220,6 +233,7 @@ abstract class ExploreEntry
'pathCount': pathCount, 'pathCount': pathCount,
'pdfCount': pdfCount, 'pdfCount': pdfCount,
if (learners != null) 'learners': learners, if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
}; };
} }
@ -251,6 +265,7 @@ abstract class ExploreEntry
'pathCount': pathCount, 'pathCount': pathCount,
'pdfCount': pdfCount, 'pdfCount': pdfCount,
if (learners != null) 'learners': learners, if (learners != null) 'learners': learners,
if (yourStudysetId != null) 'yourStudysetId': yourStudysetId?.toJson(),
}; };
} }
@ -288,6 +303,7 @@ class _ExploreEntryImpl extends ExploreEntry {
required int pathCount, required int pathCount,
required int pdfCount, required int pdfCount,
int? learners, int? learners,
_is.UuidValue? yourStudysetId,
}) : super._( }) : super._(
publicId: publicId, publicId: publicId,
location: location, location: location,
@ -313,6 +329,7 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount, pathCount: pathCount,
pdfCount: pdfCount, pdfCount: pdfCount,
learners: learners, learners: learners,
yourStudysetId: yourStudysetId,
); );
/// Returns a shallow copy of this [ExploreEntry] /// Returns a shallow copy of this [ExploreEntry]
@ -344,6 +361,7 @@ class _ExploreEntryImpl extends ExploreEntry {
int? pathCount, int? pathCount,
int? pdfCount, int? pdfCount,
Object? learners = _Undefined, Object? learners = _Undefined,
Object? yourStudysetId = _Undefined,
}) { }) {
return ExploreEntry( return ExploreEntry(
publicId: publicId ?? this.publicId, publicId: publicId ?? this.publicId,
@ -376,6 +394,9 @@ class _ExploreEntryImpl extends ExploreEntry {
pathCount: pathCount ?? this.pathCount, pathCount: pathCount ?? this.pathCount,
pdfCount: pdfCount ?? this.pdfCount, pdfCount: pdfCount ?? this.pdfCount,
learners: learners is int? ? learners : this.learners, learners: learners is int? ? learners : this.learners,
yourStudysetId: yourStudysetId is _is.UuidValue?
? yourStudysetId
: this.yourStudysetId,
); );
} }
} }

View file

@ -430,6 +430,43 @@ void main() {
} }
}); });
test('tells its author which of their studysets each of theirs was '
'published from, and nobody else', () async {
final japanese = await studyset(alice, 'Japanese');
final published = await publish(alice, japanese, 'japanese');
UuidValue? yoursIn(List<ExploreEntry> entries) => entries
.firstWhere((entry) => entry.publicId == published.publicId)
.yourStudysetId;
expect(
yoursIn(
await endpoints.explore.list(signedIn(alice), ExploreQuery()),
),
japanese,
);
expect(
yoursIn(
await endpoints.explore.entries(signedIn(alice), [
published.publicId,
]),
),
japanese,
);
expect(
yoursIn(await endpoints.explore.list(signedIn(bob), ExploreQuery())),
isNull,
);
expect(yoursIn(await list()), isNull);
expect(
yoursIn(
await endpoints.explore.entries(signedIn(bob), [
published.publicId,
]),
),
isNull,
);
});
test('says which of the studysets asked for are out, and their ' test('says which of the studysets asked for are out, and their '
'latest version', () async { 'latest version', () async {
final published = await publish( final published = await publish(