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>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-14 17:40:33 +02:00
commit fe6b1f5ed1
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
18 changed files with 14958 additions and 15 deletions

View file

@ -235,6 +235,7 @@ class ExploreEndpoint extends Endpoint {
frontLanguage: latest['frontLanguage'] as String?,
backLanguage: latest['backLanguage'] as String?,
basedOn: latest['basedOn'] as String?,
colorName: latest['colorName'] as String?,
license: latest['license'] as String,
licenseHolder: latest['licenseHolder'] as String?,
licenseYear: latest['licenseYear'] as int?,

View file

@ -23,6 +23,8 @@ fields:
backLanguage: String?
### Where the published studyset it is a copy of is, for a copy.
basedOn: String?
### Its colour, by the app palette's name for it; null for none.
colorName: String?
### Its licence and credits.
license: String
licenseHolder: String?

View file

@ -1084,6 +1084,11 @@ class Endpoints extends _is.EndpointDispatch {
type: _is.getType<String>(),
nullable: false,
),
'description': _is.ParameterDescription(
name: 'description',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
@ -1095,6 +1100,7 @@ class Endpoints extends _is.EndpointDispatch {
params['studysetId'],
params['slug'],
params['notes'],
description: params['description'],
),
),
'withdraw': _is.MethodConnector(

View file

@ -31,6 +31,7 @@ abstract class ExploreEntry
this.frontLanguage,
this.backLanguage,
this.basedOn,
this.colorName,
required this.license,
this.licenseHolder,
this.licenseYear,
@ -57,6 +58,7 @@ abstract class ExploreEntry
String? frontLanguage,
String? backLanguage,
String? basedOn,
String? colorName,
required String license,
String? licenseHolder,
int? licenseYear,
@ -88,6 +90,7 @@ abstract class ExploreEntry
frontLanguage: jsonSerialization['frontLanguage'] as String?,
backLanguage: jsonSerialization['backLanguage'] as String?,
basedOn: jsonSerialization['basedOn'] as String?,
colorName: jsonSerialization['colorName'] as String?,
license: jsonSerialization['license'] as String,
licenseHolder: jsonSerialization['licenseHolder'] as String?,
licenseYear: jsonSerialization['licenseYear'] as int?,
@ -135,6 +138,9 @@ abstract class ExploreEntry
/// Where the published studyset it is a copy of is, for a copy.
String? basedOn;
/// Its colour, by the app palette's name for it; null for none.
String? colorName;
/// Its licence and credits.
String license;
@ -175,6 +181,7 @@ abstract class ExploreEntry
String? frontLanguage,
String? backLanguage,
String? basedOn,
String? colorName,
String? license,
String? licenseHolder,
int? licenseYear,
@ -203,6 +210,7 @@ abstract class ExploreEntry
if (frontLanguage != null) 'frontLanguage': frontLanguage,
if (backLanguage != null) 'backLanguage': backLanguage,
if (basedOn != null) 'basedOn': basedOn,
if (colorName != null) 'colorName': colorName,
'license': license,
if (licenseHolder != null) 'licenseHolder': licenseHolder,
if (licenseYear != null) 'licenseYear': licenseYear,
@ -233,6 +241,7 @@ abstract class ExploreEntry
if (frontLanguage != null) 'frontLanguage': frontLanguage,
if (backLanguage != null) 'backLanguage': backLanguage,
if (basedOn != null) 'basedOn': basedOn,
if (colorName != null) 'colorName': colorName,
'license': license,
if (licenseHolder != null) 'licenseHolder': licenseHolder,
if (licenseYear != null) 'licenseYear': licenseYear,
@ -269,6 +278,7 @@ class _ExploreEntryImpl extends ExploreEntry {
String? frontLanguage,
String? backLanguage,
String? basedOn,
String? colorName,
required String license,
String? licenseHolder,
int? licenseYear,
@ -293,6 +303,7 @@ class _ExploreEntryImpl extends ExploreEntry {
frontLanguage: frontLanguage,
backLanguage: backLanguage,
basedOn: basedOn,
colorName: colorName,
license: license,
licenseHolder: licenseHolder,
licenseYear: licenseYear,
@ -323,6 +334,7 @@ class _ExploreEntryImpl extends ExploreEntry {
Object? frontLanguage = _Undefined,
Object? backLanguage = _Undefined,
Object? basedOn = _Undefined,
Object? colorName = _Undefined,
String? license,
Object? licenseHolder = _Undefined,
Object? licenseYear = _Undefined,
@ -352,6 +364,7 @@ class _ExploreEntryImpl extends ExploreEntry {
: this.frontLanguage,
backLanguage: backLanguage is String? ? backLanguage : this.backLanguage,
basedOn: basedOn is String? ? basedOn : this.basedOn,
colorName: colorName is String? ? colorName : this.colorName,
license: license ?? this.license,
licenseHolder: licenseHolder is String?
? licenseHolder

View file

@ -3415,6 +3415,12 @@ class Protocol extends _is.DatabaseSerializationManager {
isNullable: true,
dartType: 'String?',
),
_isp.ColumnDefinition(
name: 'colorName',
columnType: _isp.ColumnType.text,
isNullable: true,
dartType: 'String?',
),
],
foreignKeys: [
_isp.ForeignKeyDefinition(

View file

@ -25,6 +25,7 @@ abstract class ReleaseInfo
required this.pathCount,
required this.pdfCount,
required this.releasedAt,
this.description,
});
factory ReleaseInfo({
@ -37,6 +38,7 @@ abstract class ReleaseInfo
required int pathCount,
required int pdfCount,
required DateTime releasedAt,
String? description,
}) = _ReleaseInfoImpl;
factory ReleaseInfo.fromJson(Map<String, dynamic> jsonSerialization) {
@ -52,6 +54,7 @@ abstract class ReleaseInfo
releasedAt: _is.DateTimeJsonExtension.fromJson(
jsonSerialization['releasedAt'],
),
description: jsonSerialization['description'] as String?,
);
}
@ -77,6 +80,10 @@ abstract class ReleaseInfo
/// When it was released, in UTC.
DateTime releasedAt;
/// What Explore shows of it as its description (rich text, as its JSON);
/// null for none.
String? description;
/// Returns a shallow copy of this [ReleaseInfo]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@ -90,6 +97,7 @@ abstract class ReleaseInfo
int? pathCount,
int? pdfCount,
DateTime? releasedAt,
String? description,
});
@override
Map<String, dynamic> toJson() {
@ -104,6 +112,7 @@ abstract class ReleaseInfo
'pathCount': pathCount,
'pdfCount': pdfCount,
'releasedAt': releasedAt.toJson(),
if (description != null) 'description': description,
};
}
@ -120,6 +129,7 @@ abstract class ReleaseInfo
'pathCount': pathCount,
'pdfCount': pdfCount,
'releasedAt': releasedAt.toJson(),
if (description != null) 'description': description,
};
}
@ -129,6 +139,8 @@ abstract class ReleaseInfo
}
}
class _Undefined {}
class _ReleaseInfoImpl extends ReleaseInfo {
_ReleaseInfoImpl({
required int version,
@ -140,6 +152,7 @@ class _ReleaseInfoImpl extends ReleaseInfo {
required int pathCount,
required int pdfCount,
required DateTime releasedAt,
String? description,
}) : super._(
version: version,
notes: notes,
@ -150,6 +163,7 @@ class _ReleaseInfoImpl extends ReleaseInfo {
pathCount: pathCount,
pdfCount: pdfCount,
releasedAt: releasedAt,
description: description,
);
/// Returns a shallow copy of this [ReleaseInfo]
@ -166,6 +180,7 @@ class _ReleaseInfoImpl extends ReleaseInfo {
int? pathCount,
int? pdfCount,
DateTime? releasedAt,
Object? description = _Undefined,
}) {
return ReleaseInfo(
version: version ?? this.version,
@ -177,6 +192,7 @@ class _ReleaseInfoImpl extends ReleaseInfo {
pathCount: pathCount ?? this.pathCount,
pdfCount: pdfCount ?? this.pdfCount,
releasedAt: releasedAt ?? this.releasedAt,
description: description is String? ? description : this.description,
);
}
}

View file

@ -38,6 +38,7 @@ abstract class StudysetRelease
this.licenseHolder,
this.licenseYear,
this.basedOn,
this.colorName,
}) : title = title ?? '';
factory StudysetRelease({
@ -62,6 +63,7 @@ abstract class StudysetRelease
String? licenseHolder,
int? licenseYear,
String? basedOn,
String? colorName,
}) = _StudysetReleaseImpl;
factory StudysetRelease.fromJson(Map<String, dynamic> jsonSerialization) {
@ -89,6 +91,7 @@ abstract class StudysetRelease
licenseHolder: jsonSerialization['licenseHolder'] as String?,
licenseYear: jsonSerialization['licenseYear'] as int?,
basedOn: jsonSerialization['basedOn'] as String?,
colorName: jsonSerialization['colorName'] as String?,
);
}
@ -131,7 +134,8 @@ abstract class StudysetRelease
DateTime releasedAt;
/// What Explore shows of it, as its studyset said then: the title, the
/// secondary title, the description (rich text, as its JSON), the
/// secondary title, the description (rich text, as its JSON; the one its
/// author wrote for Explore when publishing, else the studyset's), the
/// languages (BCP 47) and the licence's credits. Kept beside the
/// repository so the listing never reads git.
String title;
@ -152,6 +156,11 @@ abstract class StudysetRelease
/// (`halkyon.eu/@alice/japanese`): what it is based on.
String? basedOn;
/// Its colour, by the name the app's palette gives it (`cobalt`), as
/// the library shows it; null for none. Kept since 2026-10-14: releases
/// before have none.
String? colorName;
@override
_is.Table<int?> get table => t;
@ -180,6 +189,7 @@ abstract class StudysetRelease
String? licenseHolder,
int? licenseYear,
String? basedOn,
String? colorName,
});
@override
Map<String, dynamic> toJson() {
@ -206,6 +216,7 @@ abstract class StudysetRelease
if (licenseHolder != null) 'licenseHolder': licenseHolder,
if (licenseYear != null) 'licenseYear': licenseYear,
if (basedOn != null) 'basedOn': basedOn,
if (colorName != null) 'colorName': colorName,
};
}
@ -267,6 +278,7 @@ class _StudysetReleaseImpl extends StudysetRelease {
String? licenseHolder,
int? licenseYear,
String? basedOn,
String? colorName,
}) : super._(
id: id,
publishedStudysetId: publishedStudysetId,
@ -289,6 +301,7 @@ class _StudysetReleaseImpl extends StudysetRelease {
licenseHolder: licenseHolder,
licenseYear: licenseYear,
basedOn: basedOn,
colorName: colorName,
);
/// Returns a shallow copy of this [StudysetRelease]
@ -317,6 +330,7 @@ class _StudysetReleaseImpl extends StudysetRelease {
Object? licenseHolder = _Undefined,
Object? licenseYear = _Undefined,
Object? basedOn = _Undefined,
Object? colorName = _Undefined,
}) {
return StudysetRelease(
id: id is int? ? id : this.id,
@ -346,6 +360,7 @@ class _StudysetReleaseImpl extends StudysetRelease {
: this.licenseHolder,
licenseYear: licenseYear is int? ? licenseYear : this.licenseYear,
basedOn: basedOn is String? ? basedOn : this.basedOn,
colorName: colorName is String? ? colorName : this.colorName,
);
}
}
@ -457,6 +472,11 @@ class StudysetReleaseUpdateTable extends _is.UpdateTable<StudysetReleaseTable> {
table.basedOn,
value,
);
_is.ColumnValue<String, String> colorName(String? value) => _is.ColumnValue(
table.colorName,
value,
);
}
class StudysetReleaseTable extends _is.Table<int?> {
@ -544,6 +564,10 @@ class StudysetReleaseTable extends _is.Table<int?> {
'basedOn',
this,
);
colorName = _is.ColumnString(
'colorName',
this,
);
}
late final StudysetReleaseUpdateTable updateTable;
@ -580,7 +604,8 @@ class StudysetReleaseTable extends _is.Table<int?> {
late final _is.ColumnDateTime releasedAt;
/// What Explore shows of it, as its studyset said then: the title, the
/// secondary title, the description (rich text, as its JSON), the
/// secondary title, the description (rich text, as its JSON; the one its
/// author wrote for Explore when publishing, else the studyset's), the
/// languages (BCP 47) and the licence's credits. Kept beside the
/// repository so the listing never reads git.
late final _is.ColumnString title;
@ -601,6 +626,11 @@ class StudysetReleaseTable extends _is.Table<int?> {
/// (`halkyon.eu/@alice/japanese`): what it is based on.
late final _is.ColumnString basedOn;
/// Its colour, by the name the app's palette gives it (`cobalt`), as
/// the library shows it; null for none. Kept since 2026-10-14: releases
/// before have none.
late final _is.ColumnString colorName;
@override
List<_is.Column> get columns => [
id,
@ -624,6 +654,7 @@ class StudysetReleaseTable extends _is.Table<int?> {
licenseHolder,
licenseYear,
basedOn,
colorName,
];
}

View file

@ -80,6 +80,11 @@ class PublishEndpoint extends Endpoint {
/// Publishes your studyset [studysetId] with everything inside it as its
/// next release, [notes] saying what changed; at the first, under the
/// name [slug] in its location, which stays its name from then on.
/// [description] (rich text, as its JSON) is what Explore shows of it,
/// when given: a studyset may introduce itself to strangers otherwise
/// than in the library (decided 2026-10-14); without, the studyset's
/// own description. One that isn't rich text, or is larger than
/// [maxExploreDescription], counts as not given.
///
/// Throws [PublishRefusedException] when it can't be published (see
/// [PublishRefusal]), and [NotFoundException] for a studyset that isn't
@ -88,8 +93,9 @@ class PublishEndpoint extends Endpoint {
Session session,
UuidValue studysetId,
String slug,
String notes,
) async {
String notes, {
String? description,
}) async {
final person = session.authenticated!.authUserId;
final key = '${person.uuid}/${studysetId.uuid}';
if (!_publishing.add(key)) {
@ -102,6 +108,7 @@ class PublishEndpoint extends Endpoint {
studysetId: studysetId,
slug: slug.trim(),
notes: notes.trim(),
description: _richText(description),
);
} finally {
_publishing.remove(key);
@ -140,6 +147,7 @@ class PublishEndpoint extends Endpoint {
required UuidValue studysetId,
required String slug,
required String notes,
required String? description,
}) async {
// SELECT … FROM "public_profile" WHERE "authUserId" = $1
final profile = await PublicProfile.db.findFirstRow(
@ -309,12 +317,14 @@ class PublishEndpoint extends Endpoint {
releasedAt: now,
title: text('title') ?? '',
secondaryTitle: text('secondaryTitle'),
description: switch (root['description']) {
final Map<String, Object?> description => jsonEncode(
description,
),
_ => null,
},
description:
description ??
switch (root['description']) {
final Map<String, Object?> description => jsonEncode(
description,
),
_ => null,
},
frontLanguage: text('frontLanguage'),
backLanguage: text('backLanguage'),
licenseHolder: text('licenseHolder'),
@ -323,6 +333,7 @@ class PublishEndpoint extends Endpoint {
_ => null,
},
basedOn: basedOn,
colorName: text('color'),
),
transaction: transaction,
);
@ -525,6 +536,7 @@ class PublishEndpoint extends Endpoint {
pathCount: release.pathCount,
pdfCount: release.pdfCount,
releasedAt: release.releasedAt,
description: release.description,
),
],
withdrawn: published.withdrawnAt != null,
@ -532,3 +544,23 @@ class PublishEndpoint extends Endpoint {
);
}
}
/// The largest description for Explore taken, as its JSON: some 50 pages
/// of text, far beyond what Explore shows.
const maxExploreDescription = 100000;
/// [json] when it is rich text as the app writes it, `{"formatVersion": …,
/// "editor": …}`, within [maxExploreDescription]; else null.
String? _richText(String? json) {
if (json == null || json.length > maxExploreDescription) {
return null;
}
try {
return switch (jsonDecode(json)) {
{'formatVersion': int(), 'editor': Map<String, Object?>()} => json,
_ => null,
};
} on FormatException {
return null;
}
}

View file

@ -14,3 +14,6 @@ fields:
pdfCount: int
### When it was released, in UTC.
releasedAt: DateTime
### What Explore shows of it as its description (rich text, as its JSON);
### null for none.
description: String?

View file

@ -24,7 +24,8 @@ fields:
### When it was released, in UTC.
releasedAt: DateTime
### What Explore shows of it, as its studyset said then: the title, the
### secondary title, the description (rich text, as its JSON), the
### secondary title, the description (rich text, as its JSON; the one its
### author wrote for Explore when publishing, else the studyset's), the
### languages (BCP 47) and the licence's credits. Kept beside the
### repository so the listing never reads git.
title: String, default=''
@ -37,6 +38,10 @@ fields:
### For a copy of a published studyset, where that one is
### (`halkyon.eu/@alice/japanese`): what it is based on.
basedOn: String?
### Its colour, by the name the app's palette gives it (`cobalt`), as
### the library shows it; null for none. Kept since 2026-10-14: releases
### before have none.
colorName: String?
indexes:
studyset_release_version_idx:
fields: publishedStudysetId, version

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,33 @@
{
"__className__": "serverpod.DatabaseMigration",
"actions": [
{
"__className__": "serverpod.DatabaseMigrationAction",
"type": "alterTable",
"alterTable": {
"__className__": "serverpod.TableMigration",
"name": "studyset_release",
"schema": "public",
"addColumns": [
{
"__className__": "serverpod.ColumnDefinition",
"name": "colorName",
"fieldName": "colorName",
"columnType": 0,
"isNullable": true,
"dartType": "String?"
}
],
"deleteColumns": [],
"modifyColumns": [],
"addIndexes": [],
"deleteIndexes": [],
"addForeignKeys": [],
"deleteForeignKeys": [],
"warnings": []
}
}
],
"warnings": [],
"migrationApiVersion": 1
}

View file

@ -0,0 +1,49 @@
BEGIN;
--
-- ACTION ALTER TABLE
--
ALTER TABLE "studyset_release" ADD COLUMN "colorName" text;
--
-- MIGRATION VERSION FOR halkyon_learn
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('halkyon_learn', '20261014153932647-release-colour', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20261014153932647-release-colour', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod', '20260824182259319', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260824182259319', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_idp
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_idp', '20260924105404509', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105404509', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_offline_sync
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_offline_sync', '20260914143806119', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260914143806119', "timestamp" = now();
--
-- MIGRATION VERSION FOR serverpod_auth_core
--
INSERT INTO "serverpod_migrations" ("module", "version", "timestamp")
VALUES ('serverpod_auth_core', '20260924105232991', now())
ON CONFLICT ("module")
DO UPDATE SET "version" = '20260924105232991', "timestamp" = now();
COMMIT;

View file

@ -27,3 +27,4 @@
20261010095547719-assistant-connector
20261012162411835-studyset-deleted-at
20261013035552510-dictionary-keys
20261014153932647-release-colour

View file

@ -162,6 +162,7 @@ void main() {
'license': ?license,
'licenseHolder': 'Alice',
'study': {'newPerDay': 10},
'color': 'cobalt',
'published': ?copyOf,
'examPlans': [
{
@ -528,6 +529,47 @@ void main() {
);
});
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 {

View file

@ -1826,8 +1826,9 @@ class _PublishEndpoint {
_ist.TestSessionBuilder sessionBuilder,
_is.UuidValue studysetId,
String slug,
String notes,
) async {
String notes, {
String? description,
}) async {
return _ist.callAwaitableFunctionAndHandleExceptions(() async {
var _localUniqueSession =
(sessionBuilder as _ist.InternalTestSessionBuilder).internalBuild(
@ -1843,6 +1844,7 @@ class _PublishEndpoint {
'studysetId': studysetId,
'slug': slug,
'notes': notes,
'description': description,
}),
serializationManager: _serializationManager,
);