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
21 changed files with 14958 additions and 15 deletions

View file

@ -746,6 +746,11 @@ class EndpointPublish extends _isc.EndpointRef {
/// Publishes your studyset [studysetId] with everything inside it as its /// Publishes your studyset [studysetId] with everything inside it as its
/// next release, [notes] saying what changed; at the first, under the /// next release, [notes] saying what changed; at the first, under the
/// name [slug] in its location, which stays its name from then on. /// 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 /// Throws [PublishRefusedException] when it can't be published (see
/// [PublishRefusal]), and [NotFoundException] for a studyset that isn't /// [PublishRefusal]), and [NotFoundException] for a studyset that isn't
@ -753,14 +758,16 @@ class EndpointPublish extends _isc.EndpointRef {
_ida.Future<_ij0cqo11.Publication> publish( _ida.Future<_ij0cqo11.Publication> publish(
_isc.UuidValue studysetId, _isc.UuidValue studysetId,
String slug, String slug,
String notes, String notes, {
) => caller.callServerEndpoint<_ij0cqo11.Publication>( String? description,
}) => caller.callServerEndpoint<_ij0cqo11.Publication>(
'publish', 'publish',
'publish', 'publish',
{ {
'studysetId': studysetId, 'studysetId': studysetId,
'slug': slug, 'slug': slug,
'notes': notes, 'notes': notes,
'description': description,
}, },
); );

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -80,6 +80,11 @@ class PublishEndpoint extends Endpoint {
/// Publishes your studyset [studysetId] with everything inside it as its /// Publishes your studyset [studysetId] with everything inside it as its
/// next release, [notes] saying what changed; at the first, under the /// next release, [notes] saying what changed; at the first, under the
/// name [slug] in its location, which stays its name from then on. /// 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 /// Throws [PublishRefusedException] when it can't be published (see
/// [PublishRefusal]), and [NotFoundException] for a studyset that isn't /// [PublishRefusal]), and [NotFoundException] for a studyset that isn't
@ -88,8 +93,9 @@ class PublishEndpoint extends Endpoint {
Session session, Session session,
UuidValue studysetId, UuidValue studysetId,
String slug, String slug,
String notes, String notes, {
) async { String? description,
}) async {
final person = session.authenticated!.authUserId; final person = session.authenticated!.authUserId;
final key = '${person.uuid}/${studysetId.uuid}'; final key = '${person.uuid}/${studysetId.uuid}';
if (!_publishing.add(key)) { if (!_publishing.add(key)) {
@ -102,6 +108,7 @@ class PublishEndpoint extends Endpoint {
studysetId: studysetId, studysetId: studysetId,
slug: slug.trim(), slug: slug.trim(),
notes: notes.trim(), notes: notes.trim(),
description: _richText(description),
); );
} finally { } finally {
_publishing.remove(key); _publishing.remove(key);
@ -140,6 +147,7 @@ class PublishEndpoint extends Endpoint {
required UuidValue studysetId, required UuidValue studysetId,
required String slug, required String slug,
required String notes, required String notes,
required String? description,
}) async { }) async {
// SELECT … FROM "public_profile" WHERE "authUserId" = $1 // SELECT … FROM "public_profile" WHERE "authUserId" = $1
final profile = await PublicProfile.db.findFirstRow( final profile = await PublicProfile.db.findFirstRow(
@ -309,7 +317,9 @@ class PublishEndpoint extends Endpoint {
releasedAt: now, releasedAt: now,
title: text('title') ?? '', title: text('title') ?? '',
secondaryTitle: text('secondaryTitle'), secondaryTitle: text('secondaryTitle'),
description: switch (root['description']) { description:
description ??
switch (root['description']) {
final Map<String, Object?> description => jsonEncode( final Map<String, Object?> description => jsonEncode(
description, description,
), ),
@ -323,6 +333,7 @@ class PublishEndpoint extends Endpoint {
_ => null, _ => null,
}, },
basedOn: basedOn, basedOn: basedOn,
colorName: text('color'),
), ),
transaction: transaction, transaction: transaction,
); );
@ -525,6 +536,7 @@ class PublishEndpoint extends Endpoint {
pathCount: release.pathCount, pathCount: release.pathCount,
pdfCount: release.pdfCount, pdfCount: release.pdfCount,
releasedAt: release.releasedAt, releasedAt: release.releasedAt,
description: release.description,
), ),
], ],
withdrawn: published.withdrawnAt != null, 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 pdfCount: int
### When it was released, in UTC. ### When it was released, in UTC.
releasedAt: DateTime 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. ### When it was released, in UTC.
releasedAt: DateTime releasedAt: DateTime
### What Explore shows of it, as its studyset said then: the title, the ### 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 ### languages (BCP 47) and the licence's credits. Kept beside the
### repository so the listing never reads git. ### repository so the listing never reads git.
title: String, default='' title: String, default=''
@ -37,6 +38,10 @@ fields:
### For a copy of a published studyset, where that one is ### For a copy of a published studyset, where that one is
### (`halkyon.eu/@alice/japanese`): what it is based on. ### (`halkyon.eu/@alice/japanese`): what it is based on.
basedOn: String? 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: indexes:
studyset_release_version_idx: studyset_release_version_idx:
fields: publishedStudysetId, version 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 20261010095547719-assistant-connector
20261012162411835-studyset-deleted-at 20261012162411835-studyset-deleted-at
20261013035552510-dictionary-keys 20261013035552510-dictionary-keys
20261014153932647-release-colour

View file

@ -162,6 +162,7 @@ void main() {
'license': ?license, 'license': ?license,
'licenseHolder': 'Alice', 'licenseHolder': 'Alice',
'study': {'newPerDay': 10}, 'study': {'newPerDay': 10},
'color': 'cobalt',
'published': ?copyOf, 'published': ?copyOf,
'examPlans': [ '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( test(
'a version git has but the database lost is never given again', 'a version git has but the database lost is never given again',
() async { () async {

Some files were not shown because too many files have changed in this diff Show more