feat(learn): confirm the notes and files a release takes

ADR 0044, D8 and D9, its first step: before publishing to Explore, the
person confirms the notes, PDFs and other files that go. Cards and
learning paths always go.

Publish's Continue leads to a step listing them by kind, none ticked at
first but those the latest release held. Each kind is ticked whole, and
an item's menu ticks everything but it or nothing but it. What isn't
ticked goes to the server as publish's leftOut. So does whatever the
sync before publishing brings that the person never saw.

The server leaves those out of the release and its counts, and leaves
out the files only they name. leftOut is optional: released apps send
none and publish everything, as before. publish.releasedMaterial names
the notes and files of the latest release, and none for a studyset not
the caller's, as for one never published.

Whats-New: Before publishing to Explore, you choose which notes and files go with the cards and learning paths.
Whats-New-De: Vor dem Veröffentlichen auf Explore wählst du, welche Notizen und Dateien mit den Karteikarten und Lernpfaden mitgehen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-11 01:54:16 +01:00
commit d664115c46
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 1202 additions and 38 deletions

View file

@ -1231,6 +1231,25 @@ class Endpoints extends _is.EndpointDispatch {
params['studysetId'],
),
),
'releasedMaterial': _is.MethodConnector(
name: 'releasedMaterial',
params: {
'studysetId': _is.ParameterDescription(
name: 'studysetId',
type: _is.getType<_is.UuidValue>(),
nullable: false,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async => (endpoints['publish'] as _iyiav298.PublishEndpoint)
.releasedMaterial(
session,
params['studysetId'],
),
),
'published': _is.MethodConnector(
name: 'published',
params: {},
@ -1269,6 +1288,11 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<Map<String, String>?>(),
nullable: true,
),
'leftOut': _is.ParameterDescription(
name: 'leftOut',
type: _is.getType<List<_is.UuidValue>?>(),
nullable: true,
),
},
call:
(
@ -1282,6 +1306,7 @@ class Endpoints extends _is.EndpointDispatch {
params['notes'],
description: params['description'],
tagNames: params['tagNames'],
leftOut: params['leftOut'],
),
),
'withdraw': _is.MethodConnector(

View file

@ -4321,6 +4321,14 @@ class Protocol extends _is.DatabaseSerializationManager {
: null)
as T;
}
if (t == _is.getType<List<_is.UuidValue>?>()) {
return (data != null
? (data as List)
.map((e) => deserialize<_is.UuidValue>(e))
.toList()
: null)
as T;
}
try {
return _ik8molhd.Protocol().deserialize<T>(data, t);
} on _is.DeserializationTypeNotFoundException catch (_) {}

View file

@ -56,6 +56,7 @@ instance:
- info:
publish:
- publication:
- releasedMaterial:
- published:
- publish:
- withdraw:

View file

@ -27,6 +27,13 @@ final _uuid = RegExp(
caseSensitive: false,
);
/// A note's or a PDF's file in a release (`notes/<id>.json`,
/// `<studyset>/pdfs/<id>.json`), its id caught.
final _materialFile = RegExp(
'(?:^|/)(?:notes|pdfs)/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-'
r'[0-9a-f]{12})\.json$',
);
/// The longest release notes, in characters.
const maxReleaseNotesLength = 5000;
@ -63,6 +70,44 @@ class PublishEndpoint extends Endpoint {
return published == null ? null : await _publicationOf(session, published);
}
/// The notes, PDFs and other files your studyset [studysetId]'s latest
/// release holds, by id, which the next release's confirmation ticks
/// from the start (ADR 0044, D9); none while it was never published, as
/// for a studyset that isn't yours.
Future<List<UuidValue>> releasedMaterial(
Session session,
UuidValue studysetId,
) async {
final person = session.authenticated!.authUserId;
// SELECT … FROM "published_studyset"
// WHERE "authUserId" = $1 AND "studysetId" = $2
final published = await PublishedStudyset.db.findFirstRow(
session,
where: (t) =>
t.authUserId.equals(person) & t.studysetId.equals(studysetId),
);
if (published == null) {
return const [];
}
// SELECT … FROM "studyset_release" WHERE "publishedStudysetId" = $1
// ORDER BY "version" DESC LIMIT 1
final latest = await StudysetRelease.db.findFirstRow(
session,
where: (t) => t.publishedStudysetId.equals(published.id),
orderBy: (t) => t.version.desc(),
);
if (latest == null) {
return const [];
}
return [
for (final path in await repositoryOf(
published.publicId,
).paths(latest.version))
if (_materialFile.firstMatch(path) case final match?)
UuidValue.fromString(match[1]!),
];
}
/// Your studysets out on Explore: published and not withdrawn, which the
/// library marks as public.
Future<List<UuidValue>> published(Session session) async {
@ -85,7 +130,10 @@ class PublishEndpoint extends Endpoint {
/// own description. One that isn't rich text, or is larger than
/// [maxExploreDescription], counts as not given. [tagNames] names the
/// tags its items carry (tag id to name), as the device reads them: the
/// person's tags are encrypted (ADR 0019, D2).
/// person's tags are encrypted (ADR 0019, D2). The notes, PDFs and
/// other files in [leftOut] stay out of it (ADR 0044, D9), as the person
/// confirmed before; null, as from apps before that step, leaves nothing
/// out.
///
/// Throws [PublishRefusedException] when it can't be published (see
/// [PublishRefusal]), and [NotFoundException] for a studyset that isn't
@ -97,6 +145,7 @@ class PublishEndpoint extends Endpoint {
String notes, {
String? description,
Map<String, String>? tagNames,
List<UuidValue>? leftOut,
}) async {
final person = session.authenticated!.authUserId;
final key = '${person.uuid}/${studysetId.uuid}';
@ -112,6 +161,7 @@ class PublishEndpoint extends Endpoint {
notes: notes.trim(),
description: _richText(description),
tagNames: tagNames,
leftOut: {...?leftOut},
);
} finally {
_publishing.remove(key);
@ -152,6 +202,7 @@ class PublishEndpoint extends Endpoint {
required String notes,
required String? description,
required Map<String, String>? tagNames,
required Set<UuidValue> leftOut,
}) async {
// Their public profile, which the instance's register keeps (ADR 0016).
final profile = await profileOf(session, person);
@ -191,6 +242,7 @@ class PublishEndpoint extends Endpoint {
person: person,
studysetId: studysetId,
tagNames: tagNames,
leftOut: leftOut,
);
final closed = [
for (final MapEntry(key: id, value: license) in content.licenses.entries)

View file

@ -61,11 +61,17 @@ final class ReleaseFiles {
/// revision), a file the content names that hasn't reached the instance
/// yet, or a tag the release needs that has no name (or a name sent for a
/// tag that isn't the person's).
///
/// The notes, PDFs and other files named in [leftOut] stay out, with the
/// files only they name (ADR 0044, D9: the person confirms the material
/// that goes); cards, learning paths and studysets always go, whatever
/// it names.
Future<ReleaseFiles> readReleaseFiles(
Session session, {
required UuidValue person,
required UuidValue studysetId,
Map<String, String>? tagNames,
Set<UuidValue> leftOut = const {},
}) async {
// SELECT "id" FROM "offline_sync_spaces" WHERE "uuidSpaceId" = $1
final space = (await OfflineSyncSpace.db.findFirstRow(
@ -250,6 +256,9 @@ Future<ReleaseFiles> readReleaseFiles(
);
var noteCount = 0;
for (final note in notes) {
if (leftOut.contains(note.id)) {
continue;
}
final heads = noteHeads[note.id!] ?? const [];
if (heads.length != 1) {
if (heads.length > 1) {
@ -356,6 +365,9 @@ Future<ReleaseFiles> readReleaseFiles(
final named = <UuidValue>{};
var pdfCount = 0;
for (final pdf in pdfs) {
if (leftOut.contains(pdf.id)) {
continue;
}
_readable(pdf.keyId);
final decoded = _decoded(pdf.payload);
if (decoded == null) {

View file

@ -87,12 +87,14 @@ void main() {
String slug = 'japanese',
String notes = '',
Map<String, String>? tagNames,
List<UuidValue>? leftOut,
}) => endpoints.publish.publish(
signedIn(person),
studyset,
slug,
notes,
tagNames: tagNames,
leftOut: leftOut,
);
Matcher refused(PublishRefusal reason, [List<UuidValue>? items]) =>
@ -426,6 +428,97 @@ void main() {
);
});
test('the notes and files the person leaves out go neither into the '
'release nor into its counts; cards and learning paths always '
'go (ADR 0044, D9)', () async {
await profile(alice, 'alice');
final content = await japanese(alice);
final published = await publish(
alice,
content.root,
leftOut: [
content.note,
content.book,
// Not to be left out: these go whatever is asked.
content.word,
content.path,
content.step,
content.kanji,
],
);
final release = published.releases.single;
expect(
[
release.studysetCount,
release.cardCount,
release.noteCount,
release.pathCount,
release.pdfCount,
],
[2, 2, 0, 1, 0],
);
final kanji = content.kanji.uuid;
final path = 'paths/${content.path.uuid}';
expect(
(await git(published.publicId, [
'ls-tree',
'-r',
'--name-only',
'v1',
])).split('\n').toSet(),
{
'studyset.json',
'cards/${content.word.uuid}.json',
'$path/path.json',
'$path/steps/${content.step.uuid}.json',
'$kanji/studyset.json',
'$kanji/cards/${content.pictured.uuid}.json',
'files.json',
// The PDF's file goes with it; the card's image stays.
'files/${content.image}',
},
);
// Ticked again, they go in the next release.
final next = await publish(alice, content.root, leftOut: const []);
expect(next.releases.first.noteCount, 1);
expect(next.releases.first.pdfCount, 1);
});
test("the latest release's notes and files are listed for the next "
"one's choice; none before the first, nor for another person's "
'studyset', () async {
await profile(alice, 'alice');
await profile(bob, 'bob');
final content = await japanese(alice);
final material = endpoints.publish.releasedMaterial;
expect(await material(signedIn(alice), content.root), isEmpty);
await publish(alice, content.root, leftOut: [content.note]);
expect(await material(signedIn(alice), content.root), [content.book]);
await publish(alice, content.root, leftOut: const []);
expect(
(await material(signedIn(alice), content.root)).toSet(),
{content.note, content.book},
);
expect(await material(signedIn(bob), content.root), isEmpty);
await expectLater(
material(
sessionBuilder.copyWith(
authentication: AuthenticationOverride.authenticationInfo(
bob.uuid,
{},
),
),
content.root,
),
throwsA(isA<ServerpodInsufficientAccessException>()),
);
});
test(
'an item is a file of its id, position, tags by name and payload '
"as synced, keys sorted, without what is only its author's",

View file

@ -1943,6 +1943,37 @@ class _PublishEndpoint {
});
}
_ida.Future<List<_is.UuidValue>> releasedMaterial(
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue studysetId,
) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
endpoint: 'publish',
method: 'releasedMaterial',
);
try {
var _localCallContext = await _endpointDispatch.getMethodCallContext(
createSessionCallback: (_) => _localUniqueSession,
endpointPath: 'publish',
methodName: 'releasedMaterial',
parameters: _ist.testObjectToJson({'studysetId': studysetId}),
serializationManager: _serializationManager,
);
var _localReturnValue =
await (_localCallContext.method.call(
_localUniqueSession,
_localCallContext.arguments,
)
as _ida.Future<List<_is.UuidValue>>);
return _localReturnValue;
} finally {
await _localUniqueSession.close();
}
});
}
_ida.Future<List<_is.UuidValue>> published(
_ist.TestSessionBuilder sessionBuilder,
) async {
@ -1980,6 +2011,7 @@ class _PublishEndpoint {
String notes, {
String? description,
Map<String, String>? tagNames,
List<_is.UuidValue>? leftOut,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
@ -1998,6 +2030,7 @@ class _PublishEndpoint {
'notes': notes,
'description': description,
'tagNames': tagNames,
'leftOut': leftOut,
}),
serializationManager: _serializationManager,
);