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:
parent
f745516f36
commit
d664115c46
7 changed files with 1202 additions and 38 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 (_) {}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ instance:
|
|||
- info:
|
||||
publish:
|
||||
- publication:
|
||||
- releasedMaterial:
|
||||
- published:
|
||||
- publish:
|
||||
- withdraw:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue