feat(learn): LanguageTool as a sidecar, reached through the server

compose.languagetool.yaml runs LanguageTool's open-source server
(meyay/languagetool 6.8-13, pinned by digest) on `edge` with no site and
no published port, as user 61081 on a read-only filesystem, without a
log. The server alone reaches it (LANGUAGETOOL_URL): grammar.check is
for those signed in, takes 20,000 characters, 60 checks a minute each
(counted in memory), maps a studyset's language to LanguageTool's code
and keeps nothing. instance.info() names the languages it checks; the
privacy page says what goes there. sidecars/README.md has the steps.

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-25 23:13:46 +01:00
commit f766a880ac
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
29 changed files with 2235 additions and 5 deletions

View file

@ -45,6 +45,8 @@ import 'package:halkyon_learn_client/src/protocol/explore/release_part.dart'
as _ijv4vyoq;
import 'package:halkyon_learn_client/src/protocol/feedback/feedback_view.dart'
as _idpwh21d;
import 'package:halkyon_learn_client/src/protocol/grammar/grammar_check.dart'
as _idjaso1z;
import 'package:halkyon_learn_client/src/protocol/instance/instance_info.dart'
as _i26u8vl4;
import 'package:halkyon_learn_client/src/protocol/publish/publication.dart'
@ -761,6 +763,35 @@ class EndpointInbox extends _isc.EndpointRef {
);
}
/// Spelling and grammar checked by the instance's LanguageTool (ADR 0024),
/// for those signed in: LanguageTool has no sign-in of its own, so the
/// server alone reaches it. Neither the text nor what was found is kept or
/// logged. Refusals are `GrammarRefusedException`s.
/// {@category Endpoint}
class EndpointGrammar extends _isc.EndpointRef {
EndpointGrammar(_isc.EndpointCaller caller) : super(caller);
@override
String get name => 'grammar';
/// What LanguageTool finds in [text], checked in [language] (a language
/// tag, `de` or `en-GB`; none: the one it recognises). Refused where the
/// instance checks nothing or its LanguageTool fails, for a language it
/// lacks, a text longer than `GrammarChecking.maxText`, and beyond
/// `GrammarChecking.perMinute` checks a minute.
_ida.Future<_idjaso1z.GrammarCheck> check({
required String text,
String? language,
}) => caller.callServerEndpoint<_idjaso1z.GrammarCheck>(
'grammar',
'check',
{
'text': text,
'language': language,
},
);
}
/// What this instance offers. Public: the app asks before anyone is signed in.
/// Released apps read the oldest build the server works with here, so it
/// keeps every field they need: halkyon_base's own basics fill those it
@ -775,8 +806,8 @@ class EndpointInstance extends _isc.EndpointRef {
/// This instance's offer: email sign-in only where it can mail codes,
/// where its source is, the oldest app build it works with, the domain its
/// people's handles are on, the Jellyfin server it suggests, and where it
/// keeps people's data and its backups.
/// people's handles are on, the Jellyfin server it suggests, the languages
/// it checks writing in, and where it keeps people's data and its backups.
_ida.Future<_i26u8vl4.InstanceInfo> info() =>
caller.callServerEndpoint<_i26u8vl4.InstanceInfo>(
'instance',
@ -918,6 +949,7 @@ class Client extends _isc.ServerpodClientShared {
learningCount = EndpointLearningCount(this);
feedback = EndpointFeedback(this);
inbox = EndpointInbox(this);
grammar = EndpointGrammar(this);
instance = EndpointInstance(this);
publish = EndpointPublish(this);
modules = Modules(this);
@ -941,6 +973,8 @@ class Client extends _isc.ServerpodClientShared {
late final EndpointInbox inbox;
late final EndpointGrammar grammar;
late final EndpointInstance instance;
late final EndpointPublish publish;
@ -958,6 +992,7 @@ class Client extends _isc.ServerpodClientShared {
'learningCount': learningCount,
'feedback': feedback,
'inbox': inbox,
'grammar': grammar,
'instance': instance,
'publish': publish,
};

View file

@ -0,0 +1,115 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../grammar/grammar_match.dart' as _idb2htis;
/// What LanguageTool found in a text (ADR 0024). Neither the text nor this is
/// kept.
abstract class GrammarCheck
implements _isc.SerializableModel, _isc.ProtocolSerialization {
GrammarCheck._({
required this.matches,
required this.language,
required this.truncated,
});
factory GrammarCheck({
required List<_idb2htis.GrammarMatch> matches,
required String language,
required bool truncated,
}) = _GrammarCheckImpl;
factory GrammarCheck.fromJson(Map<String, dynamic> jsonSerialization) {
return GrammarCheck(
matches: _itx5j3f5.Protocol().deserialize<List<_idb2htis.GrammarMatch>>(
jsonSerialization['matches'],
),
language: jsonSerialization['language'] as String,
truncated: _isc.BoolJsonExtension.fromJson(
jsonSerialization['truncated'],
),
);
}
/// What it found, in the text's order; at most 200.
List<_idb2htis.GrammarMatch> matches;
/// The language it checked in (`de-DE`): the one asked for, or the one it
/// recognised where none was.
String language;
/// Whether it found more than [matches] hold.
bool truncated;
/// Returns a shallow copy of this [GrammarCheck]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
GrammarCheck copyWith({
List<_idb2htis.GrammarMatch>? matches,
String? language,
bool? truncated,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'GrammarCheck',
'matches': matches.toJson(valueToJson: (v) => v.toJson()),
'language': language,
'truncated': truncated,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'GrammarCheck',
'matches': matches.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'language': language,
'truncated': truncated,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _GrammarCheckImpl extends GrammarCheck {
_GrammarCheckImpl({
required List<_idb2htis.GrammarMatch> matches,
required String language,
required bool truncated,
}) : super._(
matches: matches,
language: language,
truncated: truncated,
);
/// Returns a shallow copy of this [GrammarCheck]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
GrammarCheck copyWith({
List<_idb2htis.GrammarMatch>? matches,
String? language,
bool? truncated,
}) {
return GrammarCheck(
matches: matches ?? this.matches.map((e0) => e0.copyWith()).toList(),
language: language ?? this.language,
truncated: truncated ?? this.truncated,
);
}
}

View file

@ -0,0 +1,166 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Something LanguageTool found in a text it checked (ADR 0024): where, what
/// is wrong, and what could stand there instead.
abstract class GrammarMatch
implements _isc.SerializableModel, _isc.ProtocolSerialization {
GrammarMatch._({
required this.offset,
required this.length,
required this.message,
required this.shortMessage,
required this.replacements,
required this.rule,
required this.category,
});
factory GrammarMatch({
required int offset,
required int length,
required String message,
required String shortMessage,
required List<String> replacements,
required String rule,
required String category,
}) = _GrammarMatchImpl;
factory GrammarMatch.fromJson(Map<String, dynamic> jsonSerialization) {
return GrammarMatch(
offset: jsonSerialization['offset'] as int,
length: jsonSerialization['length'] as int,
message: jsonSerialization['message'] as String,
shortMessage: jsonSerialization['shortMessage'] as String,
replacements: _itx5j3f5.Protocol().deserialize<List<String>>(
jsonSerialization['replacements'],
),
rule: jsonSerialization['rule'] as String,
category: jsonSerialization['category'] as String,
);
}
/// Where it starts in the text checked, in UTF-16 code units, as Dart's
/// strings and LanguageTool's (Java's) count them.
int offset;
/// How long it is, counted so.
int length;
/// What is wrong, in a sentence, as the rule of the language checked says
/// it.
String message;
/// The same in a few words; may be empty.
String shortMessage;
/// What could stand there instead, the likeliest first; at most 8, none
/// where the rule only points something out.
List<String> replacements;
/// LanguageTool's rule (`MORFOLOGIK_RULE_EN_US`).
String rule;
/// Its category (`TYPOS`, `GRAMMAR`, `STYLE`).
String category;
/// Returns a shallow copy of this [GrammarMatch]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
GrammarMatch copyWith({
int? offset,
int? length,
String? message,
String? shortMessage,
List<String>? replacements,
String? rule,
String? category,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'GrammarMatch',
'offset': offset,
'length': length,
'message': message,
'shortMessage': shortMessage,
'replacements': replacements.toJson(),
'rule': rule,
'category': category,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'GrammarMatch',
'offset': offset,
'length': length,
'message': message,
'shortMessage': shortMessage,
'replacements': replacements.toJson(),
'rule': rule,
'category': category,
};
}
@override
String toString() {
return _isc.SerializationManager.encode(this);
}
}
class _GrammarMatchImpl extends GrammarMatch {
_GrammarMatchImpl({
required int offset,
required int length,
required String message,
required String shortMessage,
required List<String> replacements,
required String rule,
required String category,
}) : super._(
offset: offset,
length: length,
message: message,
shortMessage: shortMessage,
replacements: replacements,
rule: rule,
category: category,
);
/// Returns a shallow copy of this [GrammarMatch]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
GrammarMatch copyWith({
int? offset,
int? length,
String? message,
String? shortMessage,
List<String>? replacements,
String? rule,
String? category,
}) {
return GrammarMatch(
offset: offset ?? this.offset,
length: length ?? this.length,
message: message ?? this.message,
shortMessage: shortMessage ?? this.shortMessage,
replacements: replacements ?? this.replacements.map((e0) => e0).toList(),
rule: rule ?? this.rule,
category: category ?? this.category,
);
}
}

View file

@ -0,0 +1,52 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// Why a text wasn't checked (ADR 0024).
enum GrammarRefusal implements _isc.SerializableModel {
/// This instance checks nothing (no `LANGUAGETOOL_URL`), or its
/// LanguageTool didn't answer.
unavailable,
/// Its LanguageTool doesn't check the language asked for.
unknownLanguage,
/// Longer than one check takes (`GrammarEndpoint.maxText`).
tooLong,
/// More checks in the last minute than a person may ask for.
tooOften;
static GrammarRefusal fromJson(String name) {
switch (name) {
case 'unavailable':
return GrammarRefusal.unavailable;
case 'unknownLanguage':
return GrammarRefusal.unknownLanguage;
case 'tooLong':
return GrammarRefusal.tooLong;
case 'tooOften':
return GrammarRefusal.tooOften;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "GrammarRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,77 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod_client/serverpod_client.dart' as _isc;
import '../grammar/grammar_refusal.dart' as _i6zxv7br;
/// A text wasn't checked (ADR 0024).
abstract class GrammarRefusedException
implements
_isc.SerializableException,
_isc.SerializableModel,
_isc.ProtocolSerialization {
GrammarRefusedException._({required this.reason});
factory GrammarRefusedException({required _i6zxv7br.GrammarRefusal reason}) =
_GrammarRefusedExceptionImpl;
factory GrammarRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return GrammarRefusedException(
reason: _i6zxv7br.GrammarRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
);
}
/// Why.
_i6zxv7br.GrammarRefusal reason;
/// Returns a shallow copy of this [GrammarRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
GrammarRefusedException copyWith({_i6zxv7br.GrammarRefusal? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'GrammarRefusedException',
'reason': reason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'GrammarRefusedException',
'reason': reason.toJson(),
};
}
@override
String toString() {
return 'GrammarRefusedException(reason: $reason)';
}
}
class _GrammarRefusedExceptionImpl extends GrammarRefusedException {
_GrammarRefusedExceptionImpl({required _i6zxv7br.GrammarRefusal reason})
: super._(reason: reason);
/// Returns a shallow copy of this [GrammarRefusedException]
/// with some or all fields replaced by the given arguments.
@_isc.useResult
@override
GrammarRefusedException copyWith({_i6zxv7br.GrammarRefusal? reason}) {
return GrammarRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -10,6 +10,7 @@
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_client/src/protocol/protocol.dart' as _itx5j3f5;
import 'package:serverpod_client/serverpod_client.dart' as _isc;
/// What this instance offers, so the app can adapt: every instance is run by
@ -21,6 +22,7 @@ abstract class InstanceInfo
this.minimumAppBuild,
this.accountDomain,
this.jellyfinUrl,
this.grammarLanguages,
this.emailSignIn,
this.dataLocation,
this.backupLocation,
@ -31,6 +33,7 @@ abstract class InstanceInfo
int? minimumAppBuild,
String? accountDomain,
String? jellyfinUrl,
List<String>? grammarLanguages,
bool? emailSignIn,
String? dataLocation,
String? backupLocation,
@ -42,6 +45,11 @@ abstract class InstanceInfo
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
accountDomain: jsonSerialization['accountDomain'] as String?,
jellyfinUrl: jsonSerialization['jellyfinUrl'] as String?,
grammarLanguages: jsonSerialization['grammarLanguages'] == null
? null
: _itx5j3f5.Protocol().deserialize<List<String>>(
jsonSerialization['grammarLanguages'],
),
emailSignIn: jsonSerialization['emailSignIn'] == null
? null
: _isc.BoolJsonExtension.fromJson(jsonSerialization['emailSignIn']),
@ -71,6 +79,11 @@ abstract class InstanceInfo
/// none, and from servers that predate it.
String? jellyfinUrl;
/// The languages its LanguageTool checks spelling and grammar in (ADR
/// 0024), each without its variants (`de`, `ja`). Null where it has none,
/// while it hasn't answered, and from servers that predate it.
List<String>? grammarLanguages;
/// Whether people can sign in with an email address here (ADR 0010): a
/// code mailed to it. Null from servers that predate it: no.
bool? emailSignIn;
@ -93,6 +106,7 @@ abstract class InstanceInfo
int? minimumAppBuild,
String? accountDomain,
String? jellyfinUrl,
List<String>? grammarLanguages,
bool? emailSignIn,
String? dataLocation,
String? backupLocation,
@ -105,6 +119,8 @@ abstract class InstanceInfo
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (grammarLanguages != null)
'grammarLanguages': grammarLanguages?.toJson(),
if (emailSignIn != null) 'emailSignIn': emailSignIn,
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
@ -119,6 +135,8 @@ abstract class InstanceInfo
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (grammarLanguages != null)
'grammarLanguages': grammarLanguages?.toJson(),
if (emailSignIn != null) 'emailSignIn': emailSignIn,
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
@ -139,6 +157,7 @@ class _InstanceInfoImpl extends InstanceInfo {
int? minimumAppBuild,
String? accountDomain,
String? jellyfinUrl,
List<String>? grammarLanguages,
bool? emailSignIn,
String? dataLocation,
String? backupLocation,
@ -147,6 +166,7 @@ class _InstanceInfoImpl extends InstanceInfo {
minimumAppBuild: minimumAppBuild,
accountDomain: accountDomain,
jellyfinUrl: jellyfinUrl,
grammarLanguages: grammarLanguages,
emailSignIn: emailSignIn,
dataLocation: dataLocation,
backupLocation: backupLocation,
@ -161,6 +181,7 @@ class _InstanceInfoImpl extends InstanceInfo {
Object? minimumAppBuild = _Undefined,
Object? accountDomain = _Undefined,
Object? jellyfinUrl = _Undefined,
Object? grammarLanguages = _Undefined,
Object? emailSignIn = _Undefined,
Object? dataLocation = _Undefined,
Object? backupLocation = _Undefined,
@ -174,6 +195,9 @@ class _InstanceInfoImpl extends InstanceInfo {
? accountDomain
: this.accountDomain,
jellyfinUrl: jellyfinUrl is String? ? jellyfinUrl : this.jellyfinUrl,
grammarLanguages: grammarLanguages is List<String>?
? grammarLanguages
: this.grammarLanguages?.map((e0) => e0).toList(),
emailSignIn: emailSignIn is bool? ? emailSignIn : this.emailSignIn,
dataLocation: dataLocation is String? ? dataLocation : this.dataLocation,
backupLocation: backupLocation is String?

View file

@ -75,6 +75,10 @@ import 'feedback/feedback_message_view.dart' as _inlv2ksv;
import 'feedback/feedback_person.dart' as _i8yyqgue;
import 'feedback/feedback_thread.dart' as _ihfcaz5v;
import 'feedback/feedback_view.dart' as _in90wtl8;
import 'grammar/grammar_check.dart' as _i9fqesmy;
import 'grammar/grammar_match.dart' as _ic4rezvx;
import 'grammar/grammar_refusal.dart' as _i0nzmh1m;
import 'grammar/grammar_refused_exception.dart' as _ixpk31s8;
import 'instance/instance_info.dart' as _iskrg2g3;
import 'learning_path/learning_path.dart' as _ivt5m868;
import 'learning_path/learning_path_answer.dart' as _iy9sv3bs;
@ -132,6 +136,10 @@ export 'feedback/feedback_message_view.dart';
export 'feedback/feedback_person.dart';
export 'feedback/feedback_thread.dart';
export 'feedback/feedback_view.dart';
export 'grammar/grammar_check.dart';
export 'grammar/grammar_match.dart';
export 'grammar/grammar_refusal.dart';
export 'grammar/grammar_refused_exception.dart';
export 'instance/instance_info.dart';
export 'learning_path/learning_path.dart';
export 'learning_path/learning_path_answer.dart';
@ -2672,6 +2680,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _in90wtl8.FeedbackView) {
return _in90wtl8.FeedbackView.fromJson(data) as T;
}
if (t == _i9fqesmy.GrammarCheck) {
return _i9fqesmy.GrammarCheck.fromJson(data) as T;
}
if (t == _ic4rezvx.GrammarMatch) {
return _ic4rezvx.GrammarMatch.fromJson(data) as T;
}
if (t == _i0nzmh1m.GrammarRefusal) {
return _i0nzmh1m.GrammarRefusal.fromJson(data) as T;
}
if (t == _ixpk31s8.GrammarRefusedException) {
return _ixpk31s8.GrammarRefusedException.fromJson(data) as T;
}
if (t == _iskrg2g3.InstanceInfo) {
return _iskrg2g3.InstanceInfo.fromJson(data) as T;
}
@ -2880,6 +2900,22 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (t == _isc.getType<_in90wtl8.FeedbackView?>()) {
return (data != null ? _in90wtl8.FeedbackView.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i9fqesmy.GrammarCheck?>()) {
return (data != null ? _i9fqesmy.GrammarCheck.fromJson(data) : null) as T;
}
if (t == _isc.getType<_ic4rezvx.GrammarMatch?>()) {
return (data != null ? _ic4rezvx.GrammarMatch.fromJson(data) : null) as T;
}
if (t == _isc.getType<_i0nzmh1m.GrammarRefusal?>()) {
return (data != null ? _i0nzmh1m.GrammarRefusal.fromJson(data) : null)
as T;
}
if (t == _isc.getType<_ixpk31s8.GrammarRefusedException?>()) {
return (data != null
? _ixpk31s8.GrammarRefusedException.fromJson(data)
: null)
as T;
}
if (t == _isc.getType<_iskrg2g3.InstanceInfo?>()) {
return (data != null ? _iskrg2g3.InstanceInfo.fromJson(data) : null) as T;
}
@ -2985,6 +3021,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
.toList()
as T;
}
if (t == List<_ic4rezvx.GrammarMatch>) {
return (data as List)
.map((e) => deserialize<_ic4rezvx.GrammarMatch>(e))
.toList()
as T;
}
if (t == _isc.getType<List<String>?>()) {
return (data != null
? (data as List).map((e) => deserialize<String>(e)).toList()
: null)
as T;
}
if (t == List<_i7kd0wkp.ReleaseInfo>) {
return (data as List)
.map((e) => deserialize<_i7kd0wkp.ReleaseInfo>(e))
@ -3123,6 +3171,10 @@ class Protocol extends _isd.DatabaseSerializationManager {
_i8yyqgue.FeedbackPerson => 'FeedbackPerson',
_ihfcaz5v.FeedbackThread => 'FeedbackThread',
_in90wtl8.FeedbackView => 'FeedbackView',
_i9fqesmy.GrammarCheck => 'GrammarCheck',
_ic4rezvx.GrammarMatch => 'GrammarMatch',
_i0nzmh1m.GrammarRefusal => 'GrammarRefusal',
_ixpk31s8.GrammarRefusedException => 'GrammarRefusedException',
_iskrg2g3.InstanceInfo => 'InstanceInfo',
_ivt5m868.LearningPath => 'LearningPath',
_iy9sv3bs.LearningPathAnswer => 'LearningPathAnswer',
@ -3233,6 +3285,14 @@ class Protocol extends _isd.DatabaseSerializationManager {
return 'FeedbackThread';
case _in90wtl8.FeedbackView():
return 'FeedbackView';
case _i9fqesmy.GrammarCheck():
return 'GrammarCheck';
case _ic4rezvx.GrammarMatch():
return 'GrammarMatch';
case _i0nzmh1m.GrammarRefusal():
return 'GrammarRefusal';
case _ixpk31s8.GrammarRefusedException():
return 'GrammarRefusedException';
case _iskrg2g3.InstanceInfo():
return 'InstanceInfo';
case _ivt5m868.LearningPath():
@ -3419,6 +3479,18 @@ class Protocol extends _isd.DatabaseSerializationManager {
if (dataClassName == 'FeedbackView') {
return deserialize<_in90wtl8.FeedbackView>(data['data']);
}
if (dataClassName == 'GrammarCheck') {
return deserialize<_i9fqesmy.GrammarCheck>(data['data']);
}
if (dataClassName == 'GrammarMatch') {
return deserialize<_ic4rezvx.GrammarMatch>(data['data']);
}
if (dataClassName == 'GrammarRefusal') {
return deserialize<_i0nzmh1m.GrammarRefusal>(data['data']);
}
if (dataClassName == 'GrammarRefusedException') {
return deserialize<_ixpk31s8.GrammarRefusedException>(data['data']);
}
if (dataClassName == 'InstanceInfo') {
return deserialize<_iskrg2g3.InstanceInfo>(data['data']);
}

View file

@ -6,6 +6,7 @@ import 'package:halkyon_learn_server/src/auth/learn_base.dart';
import 'package:halkyon_learn_server/src/dictionary/device_dictionaries.dart';
import 'package:halkyon_learn_server/src/dictionary/dictionary_loader.dart';
import 'package:halkyon_learn_server/src/generated/serverpod.dart';
import 'package:halkyon_learn_server/src/grammar/grammar_checking.dart';
import 'package:halkyon_learn_server/src/instance/jellyfin_suggestion.dart';
import 'package:serverpod_auth_idp_server/core.dart';
@ -53,6 +54,8 @@ Future<void> run(List<String> args) async {
// Whether a Jellyfin server answers where the instance would suggest one
// (decided 2026-10-13), so instance.info() can name it from the first ask.
unawaited(JellyfinSuggestion.of(pod).look());
// Which languages the instance's LanguageTool checks (ADR 0024), likewise.
unawaited(GrammarChecking.of(pod).look());
// The accounts nobody signs into any more, daily (decided 2026-09-30;
// every anonymous account left since ADR 0020, D25). Scheduling always

View file

@ -55,7 +55,8 @@ HoldsRegister learnAccounts([Map<String, String>? environment]) =>
/// What the instance's privacy page says of Learn's data (ADR 0019, point 4,
/// and ADR 0021, D39): what a private studyset shows the instance, what is
/// published, what goes to it to be answered, conversations with
/// assistants (D13, D52), and the learner count.
/// assistants (D13, D52), spelling and grammar checked (ADR 0024), and the
/// learner count.
const List<PrivacyPagePoint> _privacyPagePoints = [
(
english:
@ -119,6 +120,20 @@ const List<PrivacyPagePoint> _privacyPagePoints = [
'aus Jellyfin gehen an die Instanz, um beantwortet zu werden, außer '
'dein Gerät hat die Wörterbücher; sie bewahrt nichts davon auf.',
),
(
english:
'Where the instance checks spelling and grammar, what you check and, '
'while “Check grammar while writing” is on, what you write goes to '
'its own LanguageTool, a paragraph at a time; in a private studyset '
'only once you agreed to it there. Neither the instance nor '
'LanguageTool keeps any of it.',
german:
'Wo die Instanz Rechtschreibung und Grammatik prüft, geht, was du '
'prüfst, und, solange „Grammatik beim Schreiben prüfen“ an ist, was du '
'schreibst, an ihr eigenes LanguageTool, ein Absatz nach dem anderen; '
'in einem privaten Lernset erst, nachdem du dort zugestimmt hast. '
'Weder die Instanz noch LanguageTool bewahrt etwas davon auf.',
),
(
english:
'While “Count my learning in Explore” is on, the instance keeps that '

View file

@ -34,6 +34,7 @@ import '../discussion/discussion_endpoint.dart' as _ienplr78;
import '../explore/explore_endpoint.dart' as _i897jtwc;
import '../explore/learning_count_endpoint.dart' as _is6pj3q5;
import '../feedback/feedback_endpoint.dart' as _i3n4p88h;
import '../grammar/grammar_endpoint.dart' as _icdgmtmg;
import '../instance/instance_endpoint.dart' as _i3g8bfc2;
import '../publish/publish_endpoint.dart' as _iyiav298;
export 'future_calls.dart' show ServerpodFutureCallsGetter;
@ -96,6 +97,12 @@ class Endpoints extends _is.EndpointDispatch {
'inbox',
null,
),
'grammar': _icdgmtmg.GrammarEndpoint()
..initialize(
server,
'grammar',
null,
),
'instance': _i3g8bfc2.InstanceEndpoint()
..initialize(
server,
@ -1154,6 +1161,37 @@ class Endpoints extends _is.EndpointDispatch {
),
},
);
connectors['grammar'] = _is.EndpointConnector(
name: 'grammar',
endpoint: endpoints['grammar']!,
methodConnectors: {
'check': _is.MethodConnector(
name: 'check',
params: {
'text': _is.ParameterDescription(
name: 'text',
type: _is.getType<String>(),
nullable: false,
),
'language': _is.ParameterDescription(
name: 'language',
type: _is.getType<String?>(),
nullable: true,
),
},
call:
(
_is.Session session,
Map<String, dynamic> params,
) async =>
(endpoints['grammar'] as _icdgmtmg.GrammarEndpoint).check(
session,
text: params['text'],
language: params['language'],
),
),
},
);
connectors['instance'] = _is.EndpointConnector(
name: 'instance',
endpoint: endpoints['instance']!,

View file

@ -0,0 +1,113 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_server/src/generated/protocol.dart' as _i5lsenq5;
import 'package:serverpod/serverpod.dart' as _is;
import '../grammar/grammar_match.dart' as _idb2htis;
/// What LanguageTool found in a text (ADR 0024). Neither the text nor this is
/// kept.
abstract class GrammarCheck
implements _is.SerializableModel, _is.ProtocolSerialization {
GrammarCheck._({
required this.matches,
required this.language,
required this.truncated,
});
factory GrammarCheck({
required List<_idb2htis.GrammarMatch> matches,
required String language,
required bool truncated,
}) = _GrammarCheckImpl;
factory GrammarCheck.fromJson(Map<String, dynamic> jsonSerialization) {
return GrammarCheck(
matches: _i5lsenq5.Protocol().deserialize<List<_idb2htis.GrammarMatch>>(
jsonSerialization['matches'],
),
language: jsonSerialization['language'] as String,
truncated: _is.BoolJsonExtension.fromJson(jsonSerialization['truncated']),
);
}
/// What it found, in the text's order; at most 200.
List<_idb2htis.GrammarMatch> matches;
/// The language it checked in (`de-DE`): the one asked for, or the one it
/// recognised where none was.
String language;
/// Whether it found more than [matches] hold.
bool truncated;
/// Returns a shallow copy of this [GrammarCheck]
/// with some or all fields replaced by the given arguments.
@_is.useResult
GrammarCheck copyWith({
List<_idb2htis.GrammarMatch>? matches,
String? language,
bool? truncated,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'GrammarCheck',
'matches': matches.toJson(valueToJson: (v) => v.toJson()),
'language': language,
'truncated': truncated,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'GrammarCheck',
'matches': matches.toJson(valueToJson: (v) => v.toJsonForProtocol()),
'language': language,
'truncated': truncated,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _GrammarCheckImpl extends GrammarCheck {
_GrammarCheckImpl({
required List<_idb2htis.GrammarMatch> matches,
required String language,
required bool truncated,
}) : super._(
matches: matches,
language: language,
truncated: truncated,
);
/// Returns a shallow copy of this [GrammarCheck]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
GrammarCheck copyWith({
List<_idb2htis.GrammarMatch>? matches,
String? language,
bool? truncated,
}) {
return GrammarCheck(
matches: matches ?? this.matches.map((e0) => e0.copyWith()).toList(),
language: language ?? this.language,
truncated: truncated ?? this.truncated,
);
}
}

View file

@ -0,0 +1,166 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_server/src/generated/protocol.dart' as _i5lsenq5;
import 'package:serverpod/serverpod.dart' as _is;
/// Something LanguageTool found in a text it checked (ADR 0024): where, what
/// is wrong, and what could stand there instead.
abstract class GrammarMatch
implements _is.SerializableModel, _is.ProtocolSerialization {
GrammarMatch._({
required this.offset,
required this.length,
required this.message,
required this.shortMessage,
required this.replacements,
required this.rule,
required this.category,
});
factory GrammarMatch({
required int offset,
required int length,
required String message,
required String shortMessage,
required List<String> replacements,
required String rule,
required String category,
}) = _GrammarMatchImpl;
factory GrammarMatch.fromJson(Map<String, dynamic> jsonSerialization) {
return GrammarMatch(
offset: jsonSerialization['offset'] as int,
length: jsonSerialization['length'] as int,
message: jsonSerialization['message'] as String,
shortMessage: jsonSerialization['shortMessage'] as String,
replacements: _i5lsenq5.Protocol().deserialize<List<String>>(
jsonSerialization['replacements'],
),
rule: jsonSerialization['rule'] as String,
category: jsonSerialization['category'] as String,
);
}
/// Where it starts in the text checked, in UTF-16 code units, as Dart's
/// strings and LanguageTool's (Java's) count them.
int offset;
/// How long it is, counted so.
int length;
/// What is wrong, in a sentence, as the rule of the language checked says
/// it.
String message;
/// The same in a few words; may be empty.
String shortMessage;
/// What could stand there instead, the likeliest first; at most 8, none
/// where the rule only points something out.
List<String> replacements;
/// LanguageTool's rule (`MORFOLOGIK_RULE_EN_US`).
String rule;
/// Its category (`TYPOS`, `GRAMMAR`, `STYLE`).
String category;
/// Returns a shallow copy of this [GrammarMatch]
/// with some or all fields replaced by the given arguments.
@_is.useResult
GrammarMatch copyWith({
int? offset,
int? length,
String? message,
String? shortMessage,
List<String>? replacements,
String? rule,
String? category,
});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'GrammarMatch',
'offset': offset,
'length': length,
'message': message,
'shortMessage': shortMessage,
'replacements': replacements.toJson(),
'rule': rule,
'category': category,
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'GrammarMatch',
'offset': offset,
'length': length,
'message': message,
'shortMessage': shortMessage,
'replacements': replacements.toJson(),
'rule': rule,
'category': category,
};
}
@override
String toString() {
return _is.SerializationManager.encode(this);
}
}
class _GrammarMatchImpl extends GrammarMatch {
_GrammarMatchImpl({
required int offset,
required int length,
required String message,
required String shortMessage,
required List<String> replacements,
required String rule,
required String category,
}) : super._(
offset: offset,
length: length,
message: message,
shortMessage: shortMessage,
replacements: replacements,
rule: rule,
category: category,
);
/// Returns a shallow copy of this [GrammarMatch]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
GrammarMatch copyWith({
int? offset,
int? length,
String? message,
String? shortMessage,
List<String>? replacements,
String? rule,
String? category,
}) {
return GrammarMatch(
offset: offset ?? this.offset,
length: length ?? this.length,
message: message ?? this.message,
shortMessage: shortMessage ?? this.shortMessage,
replacements: replacements ?? this.replacements.map((e0) => e0).toList(),
rule: rule ?? this.rule,
category: category ?? this.category,
);
}
}

View file

@ -0,0 +1,52 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
/// Why a text wasn't checked (ADR 0024).
enum GrammarRefusal implements _is.SerializableModel {
/// This instance checks nothing (no `LANGUAGETOOL_URL`), or its
/// LanguageTool didn't answer.
unavailable,
/// Its LanguageTool doesn't check the language asked for.
unknownLanguage,
/// Longer than one check takes (`GrammarEndpoint.maxText`).
tooLong,
/// More checks in the last minute than a person may ask for.
tooOften;
static GrammarRefusal fromJson(String name) {
switch (name) {
case 'unavailable':
return GrammarRefusal.unavailable;
case 'unknownLanguage':
return GrammarRefusal.unknownLanguage;
case 'tooLong':
return GrammarRefusal.tooLong;
case 'tooOften':
return GrammarRefusal.tooOften;
default:
throw ArgumentError(
'Value "$name" cannot be converted to "GrammarRefusal"',
);
}
}
@override
String toJson() => name;
@override
String toString() => name;
}

View file

@ -0,0 +1,77 @@
/* AUTOMATICALLY GENERATED CODE DO NOT MODIFY */
/* To generate run: "serverpod generate" */
// ignore_for_file: implementation_imports
// ignore_for_file: library_private_types_in_public_api
// ignore_for_file: non_constant_identifier_names
// ignore_for_file: public_member_api_docs
// ignore_for_file: type_literal_in_constant_pattern
// ignore_for_file: use_super_parameters
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:serverpod/serverpod.dart' as _is;
import '../grammar/grammar_refusal.dart' as _i6zxv7br;
/// A text wasn't checked (ADR 0024).
abstract class GrammarRefusedException
implements
_is.SerializableException,
_is.SerializableModel,
_is.ProtocolSerialization {
GrammarRefusedException._({required this.reason});
factory GrammarRefusedException({required _i6zxv7br.GrammarRefusal reason}) =
_GrammarRefusedExceptionImpl;
factory GrammarRefusedException.fromJson(
Map<String, dynamic> jsonSerialization,
) {
return GrammarRefusedException(
reason: _i6zxv7br.GrammarRefusal.fromJson(
(jsonSerialization['reason'] as String),
),
);
}
/// Why.
_i6zxv7br.GrammarRefusal reason;
/// Returns a shallow copy of this [GrammarRefusedException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
GrammarRefusedException copyWith({_i6zxv7br.GrammarRefusal? reason});
@override
Map<String, dynamic> toJson() {
return {
'__className__': 'GrammarRefusedException',
'reason': reason.toJson(),
};
}
@override
Map<String, dynamic> toJsonForProtocol() {
return {
'__className__': 'GrammarRefusedException',
'reason': reason.toJson(),
};
}
@override
String toString() {
return 'GrammarRefusedException(reason: $reason)';
}
}
class _GrammarRefusedExceptionImpl extends GrammarRefusedException {
_GrammarRefusedExceptionImpl({required _i6zxv7br.GrammarRefusal reason})
: super._(reason: reason);
/// Returns a shallow copy of this [GrammarRefusedException]
/// with some or all fields replaced by the given arguments.
@_is.useResult
@override
GrammarRefusedException copyWith({_i6zxv7br.GrammarRefusal? reason}) {
return GrammarRefusedException(reason: reason ?? this.reason);
}
}

View file

@ -10,6 +10,7 @@
// ignore_for_file: invalid_use_of_internal_member
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:halkyon_learn_server/src/generated/protocol.dart' as _i5lsenq5;
import 'package:serverpod/serverpod.dart' as _is;
/// What this instance offers, so the app can adapt: every instance is run by
@ -21,6 +22,7 @@ abstract class InstanceInfo
this.minimumAppBuild,
this.accountDomain,
this.jellyfinUrl,
this.grammarLanguages,
this.emailSignIn,
this.dataLocation,
this.backupLocation,
@ -31,6 +33,7 @@ abstract class InstanceInfo
int? minimumAppBuild,
String? accountDomain,
String? jellyfinUrl,
List<String>? grammarLanguages,
bool? emailSignIn,
String? dataLocation,
String? backupLocation,
@ -42,6 +45,11 @@ abstract class InstanceInfo
minimumAppBuild: jsonSerialization['minimumAppBuild'] as int?,
accountDomain: jsonSerialization['accountDomain'] as String?,
jellyfinUrl: jsonSerialization['jellyfinUrl'] as String?,
grammarLanguages: jsonSerialization['grammarLanguages'] == null
? null
: _i5lsenq5.Protocol().deserialize<List<String>>(
jsonSerialization['grammarLanguages'],
),
emailSignIn: jsonSerialization['emailSignIn'] == null
? null
: _is.BoolJsonExtension.fromJson(jsonSerialization['emailSignIn']),
@ -71,6 +79,11 @@ abstract class InstanceInfo
/// none, and from servers that predate it.
String? jellyfinUrl;
/// The languages its LanguageTool checks spelling and grammar in (ADR
/// 0024), each without its variants (`de`, `ja`). Null where it has none,
/// while it hasn't answered, and from servers that predate it.
List<String>? grammarLanguages;
/// Whether people can sign in with an email address here (ADR 0010): a
/// code mailed to it. Null from servers that predate it: no.
bool? emailSignIn;
@ -93,6 +106,7 @@ abstract class InstanceInfo
int? minimumAppBuild,
String? accountDomain,
String? jellyfinUrl,
List<String>? grammarLanguages,
bool? emailSignIn,
String? dataLocation,
String? backupLocation,
@ -105,6 +119,8 @@ abstract class InstanceInfo
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (grammarLanguages != null)
'grammarLanguages': grammarLanguages?.toJson(),
if (emailSignIn != null) 'emailSignIn': emailSignIn,
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
@ -119,6 +135,8 @@ abstract class InstanceInfo
if (minimumAppBuild != null) 'minimumAppBuild': minimumAppBuild,
if (accountDomain != null) 'accountDomain': accountDomain,
if (jellyfinUrl != null) 'jellyfinUrl': jellyfinUrl,
if (grammarLanguages != null)
'grammarLanguages': grammarLanguages?.toJson(),
if (emailSignIn != null) 'emailSignIn': emailSignIn,
if (dataLocation != null) 'dataLocation': dataLocation,
if (backupLocation != null) 'backupLocation': backupLocation,
@ -139,6 +157,7 @@ class _InstanceInfoImpl extends InstanceInfo {
int? minimumAppBuild,
String? accountDomain,
String? jellyfinUrl,
List<String>? grammarLanguages,
bool? emailSignIn,
String? dataLocation,
String? backupLocation,
@ -147,6 +166,7 @@ class _InstanceInfoImpl extends InstanceInfo {
minimumAppBuild: minimumAppBuild,
accountDomain: accountDomain,
jellyfinUrl: jellyfinUrl,
grammarLanguages: grammarLanguages,
emailSignIn: emailSignIn,
dataLocation: dataLocation,
backupLocation: backupLocation,
@ -161,6 +181,7 @@ class _InstanceInfoImpl extends InstanceInfo {
Object? minimumAppBuild = _Undefined,
Object? accountDomain = _Undefined,
Object? jellyfinUrl = _Undefined,
Object? grammarLanguages = _Undefined,
Object? emailSignIn = _Undefined,
Object? dataLocation = _Undefined,
Object? backupLocation = _Undefined,
@ -174,6 +195,9 @@ class _InstanceInfoImpl extends InstanceInfo {
? accountDomain
: this.accountDomain,
jellyfinUrl: jellyfinUrl is String? ? jellyfinUrl : this.jellyfinUrl,
grammarLanguages: grammarLanguages is List<String>?
? grammarLanguages
: this.grammarLanguages?.map((e0) => e0).toList(),
emailSignIn: emailSignIn is bool? ? emailSignIn : this.emailSignIn,
dataLocation: dataLocation is String? ? dataLocation : this.dataLocation,
backupLocation: backupLocation is String?

View file

@ -81,6 +81,10 @@ import 'feedback/feedback_person.dart' as _i8yyqgue;
import 'feedback/feedback_seen.dart' as _izm0nigq;
import 'feedback/feedback_thread.dart' as _ihfcaz5v;
import 'feedback/feedback_view.dart' as _in90wtl8;
import 'grammar/grammar_check.dart' as _i9fqesmy;
import 'grammar/grammar_match.dart' as _ic4rezvx;
import 'grammar/grammar_refusal.dart' as _i0nzmh1m;
import 'grammar/grammar_refused_exception.dart' as _ixpk31s8;
import 'instance/instance_info.dart' as _iskrg2g3;
import 'learning_path/learning_path.dart' as _ivt5m868;
import 'learning_path/learning_path_answer.dart' as _iy9sv3bs;
@ -146,6 +150,10 @@ export 'feedback/feedback_person.dart';
export 'feedback/feedback_seen.dart';
export 'feedback/feedback_thread.dart';
export 'feedback/feedback_view.dart';
export 'grammar/grammar_check.dart';
export 'grammar/grammar_match.dart';
export 'grammar/grammar_refusal.dart';
export 'grammar/grammar_refused_exception.dart';
export 'instance/instance_info.dart';
export 'learning_path/learning_path.dart';
export 'learning_path/learning_path_answer.dart';
@ -3901,6 +3909,18 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _in90wtl8.FeedbackView) {
return _in90wtl8.FeedbackView.fromJson(data) as T;
}
if (t == _i9fqesmy.GrammarCheck) {
return _i9fqesmy.GrammarCheck.fromJson(data) as T;
}
if (t == _ic4rezvx.GrammarMatch) {
return _ic4rezvx.GrammarMatch.fromJson(data) as T;
}
if (t == _i0nzmh1m.GrammarRefusal) {
return _i0nzmh1m.GrammarRefusal.fromJson(data) as T;
}
if (t == _ixpk31s8.GrammarRefusedException) {
return _ixpk31s8.GrammarRefusedException.fromJson(data) as T;
}
if (t == _iskrg2g3.InstanceInfo) {
return _iskrg2g3.InstanceInfo.fromJson(data) as T;
}
@ -4137,6 +4157,22 @@ class Protocol extends _is.DatabaseSerializationManager {
if (t == _is.getType<_in90wtl8.FeedbackView?>()) {
return (data != null ? _in90wtl8.FeedbackView.fromJson(data) : null) as T;
}
if (t == _is.getType<_i9fqesmy.GrammarCheck?>()) {
return (data != null ? _i9fqesmy.GrammarCheck.fromJson(data) : null) as T;
}
if (t == _is.getType<_ic4rezvx.GrammarMatch?>()) {
return (data != null ? _ic4rezvx.GrammarMatch.fromJson(data) : null) as T;
}
if (t == _is.getType<_i0nzmh1m.GrammarRefusal?>()) {
return (data != null ? _i0nzmh1m.GrammarRefusal.fromJson(data) : null)
as T;
}
if (t == _is.getType<_ixpk31s8.GrammarRefusedException?>()) {
return (data != null
? _ixpk31s8.GrammarRefusedException.fromJson(data)
: null)
as T;
}
if (t == _is.getType<_iskrg2g3.InstanceInfo?>()) {
return (data != null ? _iskrg2g3.InstanceInfo.fromJson(data) : null) as T;
}
@ -4250,6 +4286,18 @@ class Protocol extends _is.DatabaseSerializationManager {
.toList()
as T;
}
if (t == List<_ic4rezvx.GrammarMatch>) {
return (data as List)
.map((e) => deserialize<_ic4rezvx.GrammarMatch>(e))
.toList()
as T;
}
if (t == _is.getType<List<String>?>()) {
return (data != null
? (data as List).map((e) => deserialize<String>(e)).toList()
: null)
as T;
}
if (t == List<_i7kd0wkp.ReleaseInfo>) {
return (data as List)
.map((e) => deserialize<_i7kd0wkp.ReleaseInfo>(e))
@ -4397,6 +4445,10 @@ class Protocol extends _is.DatabaseSerializationManager {
_izm0nigq.FeedbackSeen => 'FeedbackSeen',
_ihfcaz5v.FeedbackThread => 'FeedbackThread',
_in90wtl8.FeedbackView => 'FeedbackView',
_i9fqesmy.GrammarCheck => 'GrammarCheck',
_ic4rezvx.GrammarMatch => 'GrammarMatch',
_i0nzmh1m.GrammarRefusal => 'GrammarRefusal',
_ixpk31s8.GrammarRefusedException => 'GrammarRefusedException',
_iskrg2g3.InstanceInfo => 'InstanceInfo',
_ivt5m868.LearningPath => 'LearningPath',
_iy9sv3bs.LearningPathAnswer => 'LearningPathAnswer',
@ -4521,6 +4573,14 @@ class Protocol extends _is.DatabaseSerializationManager {
return 'FeedbackThread';
case _in90wtl8.FeedbackView():
return 'FeedbackView';
case _i9fqesmy.GrammarCheck():
return 'GrammarCheck';
case _ic4rezvx.GrammarMatch():
return 'GrammarMatch';
case _i0nzmh1m.GrammarRefusal():
return 'GrammarRefusal';
case _ixpk31s8.GrammarRefusedException():
return 'GrammarRefusedException';
case _iskrg2g3.InstanceInfo():
return 'InstanceInfo';
case _ivt5m868.LearningPath():
@ -4733,6 +4793,18 @@ class Protocol extends _is.DatabaseSerializationManager {
if (dataClassName == 'FeedbackView') {
return deserialize<_in90wtl8.FeedbackView>(data['data']);
}
if (dataClassName == 'GrammarCheck') {
return deserialize<_i9fqesmy.GrammarCheck>(data['data']);
}
if (dataClassName == 'GrammarMatch') {
return deserialize<_ic4rezvx.GrammarMatch>(data['data']);
}
if (dataClassName == 'GrammarRefusal') {
return deserialize<_i0nzmh1m.GrammarRefusal>(data['data']);
}
if (dataClassName == 'GrammarRefusedException') {
return deserialize<_ixpk31s8.GrammarRefusedException>(data['data']);
}
if (dataClassName == 'InstanceInfo') {
return deserialize<_iskrg2g3.InstanceInfo>(data['data']);
}

View file

@ -50,6 +50,8 @@ inbox:
- unblock:
- blockedPeople:
- file:
grammar:
- check:
instance:
- info:
publish:

View file

@ -0,0 +1,11 @@
### What LanguageTool found in a text (ADR 0024). Neither the text nor this is
### kept.
class: GrammarCheck
fields:
### What it found, in the text's order; at most 200.
matches: List<GrammarMatch>
### The language it checked in (`de-DE`): the one asked for, or the one it
### recognised where none was.
language: String
### Whether it found more than [matches] hold.
truncated: bool

View file

@ -0,0 +1,140 @@
import 'dart:async';
import 'dart:collection';
import 'dart:io';
import 'package:clock/clock.dart';
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/grammar/language_tool.dart';
import 'package:serverpod/serverpod.dart';
/// Checking spelling and grammar on this instance (ADR 0024), through its
/// LanguageTool where it has one: which languages it checks, and checks for
/// a person, as often as [perMinute] allows. Neither the text nor what was
/// found is kept or logged; the counts live in memory alone.
class GrammarChecking {
GrammarChecking({this.languageTool});
/// The checking of [pod], made the first time it is asked for, from the
/// operator's `LANGUAGETOOL_URL` (empty: none). Tests set their own.
factory GrammarChecking.of(Serverpod pod) => _ofPod[pod] ??= GrammarChecking(
languageTool: switch (Platform.environment['LANGUAGETOOL_URL']?.trim()) {
final url? when url.isNotEmpty => LanguageTool(Uri.parse(url)),
_ => null,
},
);
static final _ofPod = Expando<GrammarChecking>('Grammar checking');
/// Makes [checking] the one of [pod].
static void use(Serverpod pod, GrammarChecking checking) =>
_ofPod[pod] = checking;
/// The instance's LanguageTool; none: it checks nothing.
final LanguageTool? languageTool;
/// The longest text one check takes, in UTF-16 code units: a paragraph
/// of a note at most, as the app checks one at a time.
static const maxText = 20000;
/// The most checks a person may ask for in a minute. The app checks a
/// paragraph once its writer pauses, and only those that changed.
static const perMinute = 60;
List<String>? _codes;
DateTime? _lookedAt;
Future<void>? _looking;
final _asked = HashMap<UuidValue, Queue<DateTime>>();
/// How long a look at the languages counts.
static const _fresh = Duration(hours: 1);
/// The languages checked here (`de`, `ja`); null without a LanguageTool,
/// or while it hasn't answered. Looks again in the background once the
/// last look is an hour old, and never waits.
List<String>? get languages {
if (_lookedAt case final lookedAt?
when clock.now().difference(lookedAt) > _fresh) {
unawaited(look());
}
return switch (_codes) {
final codes? => checkedLanguages(codes),
null => null,
};
}
/// Asks LanguageTool which languages it checks; one look at a time. A
/// failed look forgets none it knew.
Future<void> look() => _looking ??= () async {
try {
_codes = await languageTool?.languages() ?? _codes;
} on LanguageToolFailed {
// Unknown until the next look.
} finally {
_lookedAt = clock.now();
_looking = null;
}
}();
/// What LanguageTool finds in [text] of [person], checked in [language]
/// (a language tag, `de` or `en-GB`; none: the one it recognises).
/// Refused without a LanguageTool or when it fails (`unavailable`), for a
/// language it lacks, a text longer than [maxText] and more than
/// [perMinute] checks in the last minute; an empty text needs no check.
Future<GrammarCheck> check(
UuidValue person,
String text, {
String? language,
}) async {
final languageTool = this.languageTool;
if (languageTool == null) {
throw GrammarRefusedException(reason: GrammarRefusal.unavailable);
}
if (text.length > maxText) {
throw GrammarRefusedException(reason: GrammarRefusal.tooLong);
}
if (text.trim().isEmpty) {
return GrammarCheck(
matches: const [],
language: language ?? '',
truncated: false,
);
}
if (_codes == null) {
await look();
}
final codes = _codes;
if (codes == null) {
throw GrammarRefusedException(reason: GrammarRefusal.unavailable);
}
final code = languageToolCode(language, codes);
if (code == null) {
throw GrammarRefusedException(reason: GrammarRefusal.unknownLanguage);
}
_count(person);
try {
return await languageTool.check(text, code);
} on LanguageToolFailed {
throw GrammarRefusedException(reason: GrammarRefusal.unavailable);
}
}
/// Counts a check of [person]'s, refusing it beyond [perMinute] in the
/// last minute.
void _count(UuidValue person) {
final now = clock.now();
final asked = _asked.putIfAbsent(person, Queue.new);
while (asked.isNotEmpty &&
now.difference(asked.first) >= const Duration(minutes: 1)) {
asked.removeFirst();
}
if (asked.length >= perMinute) {
throw GrammarRefusedException(reason: GrammarRefusal.tooOften);
}
asked.addLast(now);
// Those who checked nothing for a minute are forgotten.
_asked.removeWhere(
(_, times) =>
times.isEmpty ||
now.difference(times.last) >= const Duration(minutes: 1),
);
}
}

View file

@ -0,0 +1,26 @@
import 'package:halkyon_learn_server/src/generated/protocol.dart';
import 'package:halkyon_learn_server/src/grammar/grammar_checking.dart';
import 'package:serverpod/serverpod.dart';
import 'package:serverpod_auth_idp_server/core.dart';
/// Spelling and grammar checked by the instance's LanguageTool (ADR 0024),
/// for those signed in: LanguageTool has no sign-in of its own, so the
/// server alone reaches it. Neither the text nor what was found is kept or
/// logged. Refusals are `GrammarRefusedException`s.
class GrammarEndpoint extends Endpoint {
@override
bool get requireLogin => true;
/// What LanguageTool finds in [text], checked in [language] (a language
/// tag, `de` or `en-GB`; none: the one it recognises). Refused where the
/// instance checks nothing or its LanguageTool fails, for a language it
/// lacks, a text longer than `GrammarChecking.maxText`, and beyond
/// `GrammarChecking.perMinute` checks a minute.
Future<GrammarCheck> check(
Session session, {
required String text,
String? language,
}) => GrammarChecking.of(
session.serverpod,
).check(session.authenticated!.authUserId, text, language: language);
}

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