import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; import 'package:crypto/crypto.dart'; import 'package:halkyon_learn_server/src/asset/asset_endpoint.dart'; import 'package:halkyon_learn_server/src/auth/account.dart'; import 'package:halkyon_learn_server/src/auth/scopes.dart'; import 'package:halkyon_learn_server/src/generated/protocol.dart'; import 'package:halkyon_learn_server/src/publish/release_repository.dart'; import 'package:serverpod/serverpod.dart'; import 'package:serverpod_auth_idp_server/core.dart'; import 'package:serverpod_offline_sync_server/serverpod_offline_sync_server.dart'; import 'package:test/test.dart'; import '../support/auth_fixtures.dart'; import '../test_tools/serverpod_test_tools.dart'; void main() { withServerpod( 'Publishing', (sessionBuilder, endpoints) { late Session session; late UuidValue alice; late UuidValue bob; late String domain; /// The people a test made, whose spaces, profiles and publications it /// deletes again. final people = {}; setUp(() async { // The test pod is built without the generated Serverpod class, which // sets up sync for the running server. Serverpod.instance.initializeOfflineSync(syncTables: syncTables); session = sessionBuilder.build(); repositoriesFolder = await Directory.systemTemp.createTemp('repos-'); alice = const Uuid().v7obj(); bob = const Uuid().v7obj(); people.addAll([alice, bob]); domain = (await endpoints.instance.info(sessionBuilder)).accountDomain!; }); tearDown(() async { await PublishedStudyset.db.deleteWhere( session, where: (t) => t.authUserId.inSet(people), ); await PublicProfile.db.deleteWhere( session, where: (t) => t.authUserId.inSet(people), ); for (final person in people) { await session.db.unsafeExecute( 'DELETE FROM "serverpod_cloud_storage" WHERE "path" LIKE @prefix', parameters: QueryParameters.named({ 'prefix': '${assetPath(person, '')}%', }), ); await session.db.unsafeExecute( 'DELETE FROM "offline_sync_spaces" WHERE "uuidSpaceId" = @space', parameters: QueryParameters.named({'space': person.uuid}), ); } people.clear(); await repositoriesFolder.delete(recursive: true); repositoriesFolder = null; }); TestSessionBuilder signedIn(UuidValue person) => sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( person.uuid, {identified}, ), ); Future profile(UuidValue person, String handle) => endpoints .profile .save(signedIn(person), handle, handle.toUpperCase(), '', []); Future publish( UuidValue person, UuidValue studyset, { String slug = 'japanese', String notes = '', }) => endpoints.publish.publish( signedIn(person), studyset, slug, notes, ); Matcher refused(PublishRefusal reason, [List? items]) => throwsA( isA() .having((error) => error.reason, 'reason', reason) .having( (error) => error.items?.toSet(), 'items', items?.toSet(), ), ); /// Writes as [person] through the sync engine, as their device would. Future asPerson( UuidValue person, Future Function(Transaction transaction) write, ) => _syncDatabases[session]!.transactionForUser(person, write); /// Runs git on the repository of the published studyset [publicId]. Future git(UuidValue publicId, List arguments) async { final result = await Process.run('git', [ '--git-dir', repositoryOf(publicId).directory.path, ...arguments, ]); expect(result.exitCode, 0, reason: '${result.stderr}'); return '${result.stdout}'.trim(); } Future> fileAt( Publication publication, String tag, String path, ) async => jsonDecode(await git(publication.publicId, ['show', '$tag:$path'])) as Map; /// A Japanese studyset of [person] (under [license]) holding a Kanji /// studyset (under its own [kanjiLicense], else the one above), with a /// card each, the first tagged and reported, the second showing an /// image; a note; a learning path with a step; and a PDF with a /// deadline in Kanji. The files are stored unless [stored] is false. Future<_Japanese> japanese( UuidValue person, { String? license = 'CC-BY-SA-4.0', String? kanjiLicense, bool stored = true, Map? copyOf, }) async { final image = utf8.encode('an image'); final pdf = utf8.encode('%PDF-1.7 a book'); final japanese = await asPerson(person, (transaction) async { Future assetOf(List bytes, String mimeType) async => (await Asset.db.insertRow( session, Asset( sha256: sha256.convert(bytes).toString(), mimeType: mimeType, byteSize: bytes.length, payload: _json({'schemaVersion': 1, 'fileName': 'a file'}), ), transaction: transaction, )).id!; final root = (await Studyset.db.insertRow( session, Studyset( payload: _json({ 'schemaVersion': 4, 'title': 'Japanese', 'license': ?license, 'licenseHolder': 'Alice', 'study': {'newPerDay': 10}, 'color': 'cobalt', 'published': ?copyOf, 'examPlans': [ { 'id': 'n5', 'name': 'JLPT N5', 'tags': ['JLPT-N5'], }, ], 'exam': {'plan': 'n5', 'date': '2026-12-06'}, }), ), transaction: transaction, )).id!; final kanji = (await Studyset.db.insertRow( session, Studyset( parentId: root, position: 'a1', payload: _json({ 'schemaVersion': 4, 'title': 'Kanji', 'license': ?kanjiLicense, }), ), transaction: transaction, )).id!; final tag = (await Tag.db.insertRow( session, Tag( payload: _json({ 'schemaVersion': 1, 'name': 'JLPT-N5', 'color': 'blue', }), ), transaction: transaction, )).id!; final imageAsset = await assetOf(image, 'image/png'); final pdfAsset = await assetOf(pdf, 'application/pdf'); Future card(UuidValue studyset, Object? front) async { final card = (await Card.db.insertRow( session, Card(studysetId: studyset, position: 'a0'), transaction: transaction, )).id!; await CardRevision.db.insertRow( session, CardRevision( cardId: card, parents: [], authorId: person, payload: _json({ 'schemaVersion': 8, 'type': 'vocabulary', 'front': front, 'back': 'a word', 'report': {'category': 'typo', 'at': '2026-10-09T00:00:00Z'}, }), ), transaction: transaction, ); return card; } final word = await card(root, '日本'); await CardTag.db.insertRow( session, CardTag(cardId: word, tagId: tag), transaction: transaction, ); final pictured = await card(kanji, { 'image': 'asset:${imageAsset.uuid}', }); final note = (await Note.db.insertRow( session, Note(studysetId: root, position: 'a0'), transaction: transaction, )).id!; await NoteRevision.db.insertRow( session, NoteRevision( noteId: note, parents: [], authorId: person, payload: _json({'schemaVersion': 2, 'title': 'Particles'}), ), transaction: transaction, ); final path = (await LearningPath.db.insertRow( session, LearningPath( studysetId: root, position: 'a0', payload: _json({'schemaVersion': 1, 'title': 'Hiragana'}), ), transaction: transaction, )).id!; final step = (await LearningPathStep.db.insertRow( session, LearningPathStep(pathId: path, position: 'a0', kind: 'info'), transaction: transaction, )).id!; await LearningPathStepRevision.db.insertRow( session, LearningPathStepRevision( stepId: step, parents: [], authorId: person, payload: _json({'schemaVersion': 2, 'title': 'あ'}), ), transaction: transaction, ); final book = (await PdfSource.db.insertRow( session, PdfSource( studysetId: kanji, payload: _json({ 'schemaVersion': 1, 'title': 'A book', 'fileName': 'book.pdf', 'asset': pdfAsset.uuid, 'deadline': {'targetPage': 10, 'due': '2026-11-01'}, }), ), transaction: transaction, )).id!; return _Japanese( root: root, kanji: kanji, word: word, pictured: pictured, note: note, path: path, step: step, book: book, image: sha256.convert(image).toString(), pdf: sha256.convert(pdf).toString(), ); }); if (stored) { for (final bytes in [image, pdf]) { await session.storage.storeFile( storageId: assetStorage, path: assetPath(person, sha256.convert(bytes).toString()), byteData: ByteData.sublistView(Uint8List.fromList(bytes)), ); } } return japanese; } /// Gives [card] of [person] another revision on [parents], the card's /// front then [front]. Future revise( UuidValue person, UuidValue card, List parents, String front, ) => asPerson( person, (transaction) => CardRevision.db.insertRow( session, CardRevision( cardId: card, parents: parents, authorId: person, payload: _json({ 'schemaVersion': 8, 'type': 'vocabulary', 'front': front, 'back': 'a word', }), ), transaction: transaction, ), ); Future> revisionsOf(UuidValue card) async => [ for (final revision in await CardRevision.db.find( session, where: (t) => t.cardId.equals(card), )) revision.id!, ]; test('a person with a public profile publishes a studyset with ' 'everything inside it as the first release of its own repository, ' 'found under their handle by its name', () async { await profile(alice, 'alice'); final content = await japanese(alice); final published = await publish( alice, content.root, notes: 'The first lessons.', ); expect(published.location, '$domain/@alice/japanese'); expect(published.studysetId, content.root); final release = published.releases.single; expect(release.version, 1); expect(release.notes, 'The first lessons.'); expect(release.license, 'CC-BY-SA-4.0'); expect( [ release.studysetCount, release.cardCount, release.noteCount, release.pathCount, release.pdfCount, ], [2, 2, 1, 1, 1], ); 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', 'notes/${content.note.uuid}.json', '$path/path.json', '$path/steps/${content.step.uuid}.json', '$kanji/studyset.json', '$kanji/cards/${content.pictured.uuid}.json', '$kanji/pdfs/${content.book.uuid}.json', 'files.json', 'files/${content.image}', 'files/${content.pdf}', }, ); expect( await git(published.publicId, ['show', 'v1:files/${content.image}']), 'an image', ); expect( await git(published.publicId, [ 'log', '-1', '--format=%an <%ae>|%cn|%B', 'v1', ]), 'ALICE |$domain|v1\n\nThe first lessons.', ); // The handle is fixed from the first publish on. expect( (await endpoints.profile.mine(signedIn(alice)))!.handleFixed, isTrue, ); }); 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", () async { await profile(alice, 'alice'); final content = await japanese(alice); final published = await publish(alice, content.root); final word = await git(published.publicId, [ 'show', 'v1:cards/${content.word.uuid}.json', ]); expect( word, const JsonEncoder.withIndent(' ').convert({ 'id': content.word.uuid, 'payload': { 'back': 'a word', 'front': '日本', 'schemaVersion': 8, 'type': 'vocabulary', }, 'position': 'a0', 'tags': ['JLPT-N5'], }), ); final book = await fileAt( published, 'v1', '${content.kanji.uuid}/pdfs/${content.book.uuid}.json', ); expect((book['payload']! as Map).containsKey('deadline'), isFalse); // The studysets' own study options are a suggestion to learners. final root = await fileAt(published, 'v1', 'studyset.json'); expect(root['payload'], containsPair('study', {'newPerDay': 10})); // Its exam plans are published; the exam its author prepares for, // with its date, is theirs alone. expect( root['payload'], containsPair('examPlans', [ { 'id': 'n5', 'name': 'JLPT N5', 'tags': ['JLPT-N5'], }, ]), ); expect((root['payload']! as Map).containsKey('exam'), isFalse); expect(root.containsKey('position'), isFalse); final files = await fileAt(published, 'v1', 'files.json'); expect(files.length, 2); }, ); test('a later release is a commit on the one before, which stays as it ' 'was; nothing changed, nothing is released', () async { await profile(alice, 'alice'); final content = await japanese(alice); await publish(alice, content.root); await expectLater( publish(alice, content.root), refused(PublishRefusal.nothingChanged), ); await revise( alice, content.word, await revisionsOf(content.word), '日本語', ); await asPerson( alice, (transaction) => Card.db.deleteWhere( session, where: (t) => t.id.equals(content.pictured), transaction: transaction, ), ); final published = await publish(alice, content.root); expect([for (final r in published.releases) r.version], [2, 1]); expect(published.releases.first.cardCount, 1); expect( await git(published.publicId, ['rev-list', '--count', 'v2']), '2', ); final card = 'cards/${content.word.uuid}.json'; expect( (await fileAt(published, 'v2', card))['payload'], containsPair('front', '日本語'), ); expect( (await fileAt(published, 'v1', card))['payload'], containsPair('front', '日本'), ); final pictured = '${content.kanji.uuid}/cards/${content.pictured.uuid}.json'; expect( await git(published.publicId, ['ls-tree', '-r', '--name-only', 'v2']), isNot(contains(pictured)), ); expect( await git(published.publicId, ['ls-tree', '-r', '--name-only', 'v1']), contains(pictured), ); }); test("a release keeps the studyset's colour, and what its author wrote " 'for Explore as its description; what is no rich text counts as ' 'nothing written', () async { await profile(alice, 'alice'); final content = await japanese(alice); final about = jsonEncode({ 'formatVersion': 1, 'editor': { 'document': {'type': 'page'}, }, }); final published = await endpoints.publish.publish( signedIn(alice), content.root, 'japanese', '', description: about, ); expect(published.releases.single.description, about); final kept = await StudysetRelease.db.find(session); expect(kept.single.colorName, 'cobalt'); expect(kept.single.description, about); await revise( alice, content.word, await revisionsOf(content.word), '日本語', ); final second = await endpoints.publish.publish( signedIn(alice), content.root, 'japanese', '', description: 'not rich text', ); expect(second.releases.first.description, isNull); }); test( 'a version git has but the database lost is never given again', () async { await profile(alice, 'alice'); final content = await japanese(alice); final first = await publish(alice, content.root); await StudysetRelease.db.deleteWhere(session, where: (t) => t.id > 0); await revise( alice, content.word, await revisionsOf(content.word), 'x', ); final published = await publish(alice, content.root); expect(published.releases.single.version, 2); expect(published.publicId, first.publicId); }, ); test('only under licences that allow passing it on, every studyset ' 'inside included; nothing published otherwise', () async { await profile(alice, 'alice'); final unlicensed = await japanese(alice, license: null); await expectLater( publish(alice, unlicensed.root), refused(PublishRefusal.licenseNotOpen, [ unlicensed.root, unlicensed.kanji, ]), ); final reserved = await japanese( alice, license: 'CC-BY-NC-ND-4.0', kanjiLicense: 'All Rights Reserved', ); await expectLater( publish(alice, reserved.root, slug: 'reserved'), refused(PublishRefusal.licenseNotOpen, [reserved.kanji]), ); expect(await PublishedStudyset.db.count(session), 0); expect(repositoriesFolder.listSync(), isEmpty); // "No derivatives" allows passing it on as it is. final verbatim = await japanese(alice, license: 'CC-BY-NC-ND-4.0'); final published = await publish(alice, verbatim.root, slug: 'nd'); expect(published.releases.single.license, 'CC-BY-NC-ND-4.0'); }); test('a card with a conflict to settle is named, and nothing is ' 'published', () async { await profile(alice, 'alice'); final content = await japanese(alice); final first = await revisionsOf(content.word); await revise(alice, content.word, first, 'one device'); await revise(alice, content.word, first, 'another device'); await expectLater( publish(alice, content.root), refused(PublishRefusal.conflicts, [content.word]), ); expect(await PublishedStudyset.db.count(session), 0); }); test('files the content names must have reached the instance', () async { await profile(alice, 'alice'); final content = await japanese(alice, stored: false); await expectLater( publish(alice, content.root), refused(PublishRefusal.filesMissing), ); expect(repositoriesFolder.listSync(), isEmpty); }); test('a studyset being deleted is left out with everything inside ' 'it', () async { await profile(alice, 'alice'); final content = await japanese(alice); // Blank, as a device's deletion leaves it on the server (the app's // sync tests check that). Written in SQL: the sync engine (checked // with v0.0.10) stores a ByteData the server updates as its JSON // text, `decode('…', 'base64')`, not as its bytes. await session.db.unsafeExecute( 'UPDATE "studyset" SET "payload" = \'\'::bytea WHERE "id" = @id', parameters: QueryParameters.named({'id': content.kanji.uuid}), ); final published = await publish(alice, content.root); expect( await git(published.publicId, ['ls-tree', '--name-only', 'v1']), isNot(contains(content.kanji.uuid)), ); expect(published.releases.single.cardCount, 1); }); test('a studyset waiting in Recently deleted is left out with ' 'everything inside it, and not published itself', () async { await profile(alice, 'alice'); final content = await japanese(alice); // Set as a device's deletion sets it; in SQL, as the test above // writes. Future delete(UuidValue studyset) => session.db.unsafeExecute( 'UPDATE "studyset" SET "deletedAt" = now() WHERE "id" = @id', parameters: QueryParameters.named({'id': studyset.uuid}), ); await delete(content.kanji); final published = await publish(alice, content.root); expect( await git(published.publicId, ['ls-tree', '--name-only', 'v1']), isNot(contains(content.kanji.uuid)), ); expect(published.releases.single.cardCount, 1); await delete(content.root); await expectLater( publish(alice, content.root), throwsA(isA()), ); }); test('its name is lower-case letters, digits and hyphens, one per ' 'studyset of its person, and fixed from the first publish', () async { await profile(alice, 'alice'); await profile(bob, 'bob'); final content = await japanese(alice); for (final slug in ['Japanese', '-japanese', 'に', 'a' * 51, '']) { await expectLater( publish(alice, content.root, slug: slug), refused(PublishRefusal.slugInvalid), reason: slug, ); } await publish(alice, content.root); final another = await japanese(alice); await expectLater( publish(alice, another.root), refused(PublishRefusal.slugTaken), ); await expectLater( publish(alice, content.root, slug: 'nihongo'), refused(PublishRefusal.slugFixed), ); // Another person's name is theirs. final bobs = await japanese(bob); expect( (await publish(bob, bobs.root)).location, '$domain/@bob/japanese', ); }); test('notes are at most 5,000 characters', () async { await profile(alice, 'alice'); final content = await japanese(alice); await expectLater( publish(alice, content.root, notes: 'x' * 5001), refused(PublishRefusal.notesTooLong), ); }); group('A copy of a published studyset', () { /// Where Alice's Japanese, published under [license], is, as a copy /// of it names it. Future> original(String license) async { final published = await publish( alice, (await japanese(alice, license: license)).root, ); return { 'publication': published.publicId.uuid, 'item': 'root', 'version': 1, 'location': published.location, }; } test('is published as based on the original', () async { await profile(alice, 'alice'); await profile(bob, 'bob'); final origin = await original('CC-BY-4.0'); final copy = await japanese( bob, license: 'CC-BY-4.0', copyOf: origin, ); final published = await publish(bob, copy.root, slug: 'nihongo'); final entry = (await endpoints.explore.entries(signedIn(bob), [ published.publicId, ])).single; expect(entry.basedOn, origin['location']); }); test('is refused under "no derivatives"', () async { await profile(alice, 'alice'); await profile(bob, 'bob'); final copy = await japanese( bob, license: 'CC-BY-NC-ND-4.0', copyOf: await original('CC-BY-NC-ND-4.0'), ); await expectLater( publish(bob, copy.root), refused(PublishRefusal.forkForbidden), ); }); test('keeps a ShareAlike licence, in every studyset of it', () async { await profile(alice, 'alice'); await profile(bob, 'bob'); final origin = await original('CC-BY-SA-4.0'); final relicensed = await japanese( bob, license: 'CC-BY-4.0', copyOf: origin, ); await expectLater( publish(bob, relicensed.root), throwsA( isA() .having( (e) => e.reason, 'reason', PublishRefusal.licenseMustStay, ) .having((e) => e.license, 'license', 'CC-BY-SA-4.0'), ), ); final mixed = await japanese( bob, kanjiLicense: 'CC0-1.0', copyOf: origin, ); await expectLater( publish(bob, mixed.root, slug: 'mixed'), refused(PublishRefusal.licenseMustStay), ); final kept = await japanese(bob, copyOf: origin); expect( (await publish(bob, kept.root, slug: 'kept')).releases, hasLength(1), ); }); test("is its author's to publish when they copied their own, and " "one of another instance's goes with where that is", () async { await profile(alice, 'alice'); final mine = await japanese( alice, license: 'CC-BY-4.0', copyOf: await original('CC-BY-NC-ND-4.0'), ); expect( (await publish(alice, mine.root, slug: 'again')).releases, hasLength(1), ); final elsewhere = await japanese( alice, copyOf: { 'publication': const Uuid().v4obj().uuid, 'item': 'root', 'version': 3, 'location': 'learn.example.org/@carol/japanese', }, ); final published = await publish( alice, elsewhere.root, slug: 'elsewhere', ); final entry = (await endpoints.explore.entries(signedIn(alice), [ published.publicId, ])).single; expect(entry.basedOn, 'learn.example.org/@carol/japanese'); }); }); test('one release of a studyset at a time', () async { await profile(alice, 'alice'); final content = await japanese(alice); final outcomes = await Future.wait([ for (var i = 0; i < 2; i++) publish(alice, content.root).then( (published) => published.releases.single.version, onError: (Object error) => (error as PublishRefusedException).reason, ), ]); expect(outcomes.toSet(), {1, PublishRefusal.busy}); }); group('Refused', () { test('without signing in, or with an anonymous account', () async { final content = await japanese(alice); final anonymous = sessionBuilder.copyWith( authentication: AuthenticationOverride.authenticationInfo( alice.uuid, {}, ), ); await expectLater( endpoints.publish.publish(sessionBuilder, content.root, 'a', ''), throwsA(isA()), ); await expectLater( endpoints.publish.publish(anonymous, content.root, 'a', ''), throwsA(isA()), ); await expectLater( endpoints.publish.publication(anonymous, content.root), throwsA(isA()), ); }); test('without a public profile: a release names its author', () async { final content = await japanese(alice); await expectLater( publish(alice, content.root), refused(PublishRefusal.noProfile), ); }); test("for another person's studyset, which can't be told from one " "that doesn't exist", () async { await profile(alice, 'alice'); await profile(bob, 'bob'); final content = await japanese(alice); await publish(alice, content.root); for (final studyset in [content.root, const Uuid().v7obj()]) { await expectLater( publish(bob, studyset), throwsA(isA()), ); } expect( await endpoints.publish.publication(signedIn(bob), content.root), isNull, ); expect(await PublishedStudyset.db.count(session), 1); }); }); test( 'a publication is null until published, then lists its releases', () async { await profile(alice, 'alice'); final content = await japanese(alice); expect( await endpoints.publish.publication( signedIn(alice), content.root, ), isNull, ); final published = await publish(alice, content.root); expect( (await endpoints.publish.publication( signedIn(alice), content.root, ))!.toJson(), published.toJson(), ); }, ); test("the studysets out on Explore are the person's published ones " 'not withdrawn', () async { await profile(alice, 'alice'); await profile(bob, 'bob'); final alices = await japanese(alice); final bobs = await japanese(bob); expect(await endpoints.publish.published(signedIn(alice)), isEmpty); await publish(alice, alices.root); await publish(bob, bobs.root); await endpoints.publish.withdraw(signedIn(bob), bobs.root); expect(await endpoints.publish.published(signedIn(alice)), [ alices.root, ]); expect(await endpoints.publish.published(signedIn(bob)), isEmpty); }); test('deleting a person deletes their published studysets and their ' 'repositories', () async { useServerAuth(); final carol = (await AuthServices.instance.authUsers.create(session)) .id; people.add(carol); await profile(carol, 'carol'); final content = await japanese(carol); final published = await publish(carol, content.root); expect(repositoryOf(published.publicId).directory.existsSync(), isTrue); await deletePerson(session, carol); expect(await PublishedStudyset.db.count(session), 0); expect(await StudysetRelease.db.count(session), 0); expect( repositoryOf(published.publicId).directory.existsSync(), isFalse, ); }); }, // The sync engine's database, as the running server has it: deleted rows // are tombstones it hides. Writing through it takes transactions of its // own, outside the one a test would be rolled back in, so the tests // delete what they made instead. databaseInterceptor: (session, inner) { final database = offlineSyncDatabaseInterceptor(session, inner); if (database is OfflineSyncDatabase) { _syncDatabases[session] = database; } return database; }, rollbackDatabase: RollbackDatabase.disabled, ); } /// The sync engine's database of each session, as the test pod's own /// database wraps it. final _syncDatabases = Expando(); /// [json] as a synced payload. ByteData _json(Map json) => ByteData.sublistView(utf8.encode(jsonEncode(json))); /// The ids of what `japanese` writes, and the hashes of its files. final class _Japanese { _Japanese({ required this.root, required this.kanji, required this.word, required this.pictured, required this.note, required this.path, required this.step, required this.book, required this.image, required this.pdf, }); final UuidValue root; final UuidValue kanji; final UuidValue word; final UuidValue pictured; final UuidValue note; final UuidValue path; final UuidValue step; final UuidValue book; final String image; final String pdf; }