feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
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 = <UuidValue>{};
|
|
|
|
|
|
|
|
|
|
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> profile(UuidValue person, String handle) => endpoints
|
|
|
|
|
.profile
|
|
|
|
|
.save(signedIn(person), handle, handle.toUpperCase(), '', []);
|
|
|
|
|
|
|
|
|
|
Future<Publication> publish(
|
|
|
|
|
UuidValue person,
|
|
|
|
|
UuidValue studyset, {
|
|
|
|
|
String slug = 'japanese',
|
|
|
|
|
String notes = '',
|
|
|
|
|
}) => endpoints.publish.publish(
|
|
|
|
|
signedIn(person),
|
|
|
|
|
studyset,
|
|
|
|
|
slug,
|
|
|
|
|
notes,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
Matcher refused(PublishRefusal reason, [List<UuidValue>? items]) =>
|
|
|
|
|
throwsA(
|
|
|
|
|
isA<PublishRefusedException>()
|
|
|
|
|
.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<T> asPerson<T>(
|
|
|
|
|
UuidValue person,
|
|
|
|
|
Future<T> Function(Transaction transaction) write,
|
|
|
|
|
) => _syncDatabases[session]!.transactionForUser(person, write);
|
|
|
|
|
|
|
|
|
|
/// Runs git on the repository of the published studyset [publicId].
|
|
|
|
|
Future<String> git(UuidValue publicId, List<String> 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<Map<String, Object?>> fileAt(
|
|
|
|
|
Publication publication,
|
|
|
|
|
String tag,
|
|
|
|
|
String path,
|
|
|
|
|
) async =>
|
|
|
|
|
jsonDecode(await git(publication.publicId, ['show', '$tag:$path']))
|
|
|
|
|
as Map<String, Object?>;
|
|
|
|
|
|
|
|
|
|
/// 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,
|
feat(explore): how many learn it here, opt-in; copies published within their licence
Explore says how many people learn a published studyset on this instance
(ADR 0005, rule 8): shown, never ranked, counted daily by the server from
the people with an identity who chose to be counted (Settings, Privacy;
settings schema 8) and studied a copy on 3 or more days of the last 30,
rounded down from 5 on, so that nobody can be told apart. Only a number
the instance computes itself counts; votes count for nothing. The author
sees the same rounded number in the Publish sheet.
A copy of a published studyset is published within the original's
licence: never under "no derivatives", under a licence that keeps
adaptations under it only under that one, in every studyset of the copy.
Its releases say what they are based on, and Explore shows it.
ADR 0005 gets an addendum with stage 3's decisions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 00:18:29 +02:00
|
|
|
Map<String, Object?>? copyOf,
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
}) async {
|
|
|
|
|
final image = utf8.encode('an image');
|
|
|
|
|
final pdf = utf8.encode('%PDF-1.7 a book');
|
|
|
|
|
final japanese = await asPerson(person, (transaction) async {
|
|
|
|
|
Future<UuidValue> assetOf(List<int> 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},
|
feat(publish): a release keeps its colour, and a description for Explore
Publishing takes a description for Explore (rich text, as its JSON),
which Explore shows in place of the studyset's own: a studyset may
introduce itself to strangers otherwise than in the library, where its
author may leave it empty. Without one, as from older apps, the
studyset's description as before; one that isn't rich text, or over
100,000 characters, counts as none. A release also keeps the studyset's
colour, which Explore gives out with it; releases before have none. The
publication's releases say their description, for the next publish to
start from.
Migration release-colour adds a nullable column:
ALTER TABLE "studyset_release" ADD COLUMN "colorName" text;
the server before it reads and writes the table as ever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 17:40:33 +02:00
|
|
|
'color': 'cobalt',
|
feat(explore): how many learn it here, opt-in; copies published within their licence
Explore says how many people learn a published studyset on this instance
(ADR 0005, rule 8): shown, never ranked, counted daily by the server from
the people with an identity who chose to be counted (Settings, Privacy;
settings schema 8) and studied a copy on 3 or more days of the last 30,
rounded down from 5 on, so that nobody can be told apart. Only a number
the instance computes itself counts; votes count for nothing. The author
sees the same rounded number in the Publish sheet.
A copy of a published studyset is published within the original's
licence: never under "no derivatives", under a licence that keeps
adaptations under it only under that one, in every studyset of the copy.
Its releases say what they are based on, and Explore shows it.
ADR 0005 gets an addendum with stage 3's decisions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 00:18:29 +02:00
|
|
|
'published': ?copyOf,
|
feat(studyset): prepare for an exam, with plans, a date and the pace to it
A studyset plans for exams: each plan names what an exam covers, the
topics it takes whole (with their flashcards, learning paths and PDFs)
and the tags whose flashcards it takes from anywhere in the studyset.
The author's plans are published with the studyset (payload schema 7,
`examPlans`); copying a release and taking a newer one point their
topics at the copy's own studysets. Plans are also offered from level
tags: JLPT N5 to N1, HSK 1 to 7-9 and KRDict's levels, each taking the
levels below it, as the exams do.
The person chooses a plan and the exam's day (`exam`), which stays
theirs: the server leaves it out of a release, as a PDF's deadline.
The study plan then shows the days left, how many of the plan's
flashcards were started, its paths' steps done and its PDFs' pages
read, and the pace that gets everything new learnt a week before the
exam (by the exam when two weeks or fewer are left): new flashcards a
day, steps a week, pages a day. Study for the exam studies only what
the plan covers, its new cards at that pace (counted as the day began)
and every review due.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 14:26:08 +02:00
|
|
|
'examPlans': [
|
|
|
|
|
{
|
|
|
|
|
'id': 'n5',
|
|
|
|
|
'name': 'JLPT N5',
|
|
|
|
|
'tags': ['JLPT-N5'],
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
'exam': {'plan': 'n5', 'date': '2026-12-06'},
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
}),
|
|
|
|
|
),
|
|
|
|
|
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<UuidValue> 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<void> revise(
|
|
|
|
|
UuidValue person,
|
|
|
|
|
UuidValue card,
|
|
|
|
|
List<UuidValue> 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<List<UuidValue>> 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 <alice@$domain>|$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}));
|
feat(studyset): prepare for an exam, with plans, a date and the pace to it
A studyset plans for exams: each plan names what an exam covers, the
topics it takes whole (with their flashcards, learning paths and PDFs)
and the tags whose flashcards it takes from anywhere in the studyset.
The author's plans are published with the studyset (payload schema 7,
`examPlans`); copying a release and taking a newer one point their
topics at the copy's own studysets. Plans are also offered from level
tags: JLPT N5 to N1, HSK 1 to 7-9 and KRDict's levels, each taking the
levels below it, as the exams do.
The person chooses a plan and the exam's day (`exam`), which stays
theirs: the server leaves it out of a release, as a PDF's deadline.
The study plan then shows the days left, how many of the plan's
flashcards were started, its paths' steps done and its PDFs' pages
read, and the pace that gets everything new learnt a week before the
exam (by the exam when two weeks or fewer are left): new flashcards a
day, steps a week, pages a day. Study for the exam studies only what
the plan covers, its new cards at that pace (counted as the day began)
and every review due.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 14:26:08 +02:00
|
|
|
// 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);
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): a release keeps its colour, and a description for Explore
Publishing takes a description for Explore (rich text, as its JSON),
which Explore shows in place of the studyset's own: a studyset may
introduce itself to strangers otherwise than in the library, where its
author may leave it empty. Without one, as from older apps, the
studyset's description as before; one that isn't rich text, or over
100,000 characters, counts as none. A release also keeps the studyset's
colour, which Explore gives out with it; releases before have none. The
publication's releases say their description, for the next publish to
start from.
Migration release-colour adds a nullable column:
ALTER TABLE "studyset_release" ADD COLUMN "colorName" text;
the server before it reads and writes the table as ever.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-14 17:40:33 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-10-12 18:50:10 +02:00
|
|
|
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<void> 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<NotFoundException>()),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
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),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
feat(explore): how many learn it here, opt-in; copies published within their licence
Explore says how many people learn a published studyset on this instance
(ADR 0005, rule 8): shown, never ranked, counted daily by the server from
the people with an identity who chose to be counted (Settings, Privacy;
settings schema 8) and studied a copy on 3 or more days of the last 30,
rounded down from 5 on, so that nobody can be told apart. Only a number
the instance computes itself counts; votes count for nothing. The author
sees the same rounded number in the Publish sheet.
A copy of a published studyset is published within the original's
licence: never under "no derivatives", under a licence that keeps
adaptations under it only under that one, in every studyset of the copy.
Its releases say what they are based on, and Explore shows it.
ADR 0005 gets an addendum with stage 3's decisions.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-10 00:18:29 +02:00
|
|
|
group('A copy of a published studyset', () {
|
|
|
|
|
/// Where Alice's Japanese, published under [license], is, as a copy
|
|
|
|
|
/// of it names it.
|
|
|
|
|
Future<Map<String, Object?>> 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<PublishRefusedException>()
|
|
|
|
|
.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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
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<Object>(
|
|
|
|
|
(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<ServerpodUnauthenticatedException>()),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.publish.publish(anonymous, content.root, 'a', ''),
|
|
|
|
|
throwsA(isA<ServerpodInsufficientAccessException>()),
|
|
|
|
|
);
|
|
|
|
|
await expectLater(
|
|
|
|
|
endpoints.publish.publication(anonymous, content.root),
|
|
|
|
|
throwsA(isA<ServerpodInsufficientAccessException>()),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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<NotFoundException>()),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
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(),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-10 05:17:10 +02:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
feat(publish): releases in git (public tier, stage 2)
A person with an identity and a public profile publishes a studyset with
everything inside it as its next release (ADR 0005, rule 3): the server
reads their synced rows and writes them through the git command as a
commit on main and the annotated tag vN, in a bare repository of the
studyset's own, named by a random public id made at the first publish.
A release is a directory per studyset with a file per card, note, path,
step and PDF (its id, position, tag names and payload as synced, keys
sorted and indented) and the files the content names, without what is
only the author's (a card's report, a PDF's deadline). Publishing is
refused without a licence allowing passing it on in every studyset ("no
derivatives" allowed), with revisions to settle, with files not uploaded
yet, when nothing changed, or for a name that won't do; the name is
fixed from the first release, as is the person's handle.
In the app, Share in a studyset's action row opens the Publish sheet;
publishing syncs first, and settles by itself the revisions that merge
cleanly, listing only real conflicts. The server's image gets git, the
repositories a volume of their own, and the hourly backup a git bundle of
each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
2026-10-09 22:01:00 +02:00
|
|
|
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<OfflineSyncDatabase>();
|
|
|
|
|
|
|
|
|
|
/// [json] as a synced payload.
|
|
|
|
|
ByteData _json(Map<String, Object?> 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;
|
|
|
|
|
}
|