fix(publish): a release sends exactly the tag names the sheet showed (ADR 0019)

The final reading of 22 Oct found that the names were worked out again
after the sync that comes before publishing, so a link to a private tag
the instance merged in during that sync sent a name nobody saw going. The
sheet now hands what it showed to Publish, and the device refuses before
anything goes out when the names worked out then differ
(PublishProblem.tagNamesChanged), reading them again for the person to
look at.

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-22 11:30:17 +02:00
commit 92ff7616a4
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
13 changed files with 164 additions and 13 deletions

View file

@ -2065,6 +2065,7 @@
"publishPrivateRefused": "Es ist privat: Nur deine Geräte können es lesen. Mach es zuerst in seinem Menü nicht mehr privat.", "publishPrivateRefused": "Es ist privat: Nur deine Geräte können es lesen. Mach es zuerst in seinem Menü nicht mehr privat.",
"publishTagNames": "Nicht alle seine Tags ließen sich benennen. Synchronisiere und versuche es noch einmal.", "publishTagNames": "Nicht alle seine Tags ließen sich benennen. Synchronisiere und versuche es noch einmal.",
"publishTagNamesChanged": "Die Tags, die mitgehen würden, haben sich geändert, seit sie angezeigt wurden. Sieh sie dir noch einmal an und veröffentliche dann.",
"studysetMenuMakePrivate": "Privat machen", "studysetMenuMakePrivate": "Privat machen",
"studysetMenuMakeReadable": "Nicht mehr privat", "studysetMenuMakeReadable": "Nicht mehr privat",
"studysetPrivateTitle": "„{title}“ privat machen?", "studysetPrivateTitle": "„{title}“ privat machen?",

View file

@ -2429,6 +2429,7 @@
"publishPrivateRefused": "It's private: only your devices can read it. Make it non-private first, in its menu.", "publishPrivateRefused": "It's private: only your devices can read it. Make it non-private first, in its menu.",
"publishTagNames": "Its tags couldn't all be named. Sync, then try again.", "publishTagNames": "Its tags couldn't all be named. Sync, then try again.",
"publishTagNamesChanged": "The tags that would go with it changed since they were shown. Look at them again, then publish.",
"studysetMenuMakePrivate": "Make private", "studysetMenuMakePrivate": "Make private",
"studysetMenuMakeReadable": "Make non-private", "studysetMenuMakeReadable": "Make non-private",
"studysetPrivateTitle": "Make “{title}” private?", "studysetPrivateTitle": "Make “{title}” private?",

View file

@ -12177,6 +12177,12 @@ abstract class AppLocalizations {
/// **'Its tags couldn\'t all be named. Sync, then try again.'** /// **'Its tags couldn\'t all be named. Sync, then try again.'**
String get publishTagNames; String get publishTagNames;
/// No description provided for @publishTagNamesChanged.
///
/// In en, this message translates to:
/// **'The tags that would go with it changed since they were shown. Look at them again, then publish.'**
String get publishTagNamesChanged;
/// No description provided for @studysetMenuMakePrivate. /// No description provided for @studysetMenuMakePrivate.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -7719,6 +7719,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get publishTagNames => String get publishTagNames =>
'Nicht alle seine Tags ließen sich benennen. Synchronisiere und versuche es noch einmal.'; 'Nicht alle seine Tags ließen sich benennen. Synchronisiere und versuche es noch einmal.';
@override
String get publishTagNamesChanged =>
'Die Tags, die mitgehen würden, haben sich geändert, seit sie angezeigt wurden. Sieh sie dir noch einmal an und veröffentliche dann.';
@override @override
String get studysetMenuMakePrivate => 'Privat machen'; String get studysetMenuMakePrivate => 'Privat machen';

View file

@ -7646,6 +7646,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get publishTagNames => String get publishTagNames =>
'Its tags couldn\'t all be named. Sync, then try again.'; 'Its tags couldn\'t all be named. Sync, then try again.';
@override
String get publishTagNamesChanged =>
'The tags that would go with it changed since they were shown. Look at them again, then publish.';
@override @override
String get studysetMenuMakePrivate => 'Make private'; String get studysetMenuMakePrivate => 'Make private';

View file

@ -87,6 +87,7 @@ class PublishBloc(
slug: event.slug, slug: event.slug,
notes: event.notes, notes: event.notes,
description: event.description, description: event.description,
tagNamesShown: event.tagNames,
); );
} }
@ -300,10 +301,15 @@ final class const PublishEventRequested({
required final String notes, required final String notes,
required final List<String> subtree, required final List<String> subtree,
/// The names of the tags the sheet showed going with it, by their ids;
/// null where it showed none. Nothing else goes (the final reading of 22
/// Oct).
required final Map<String, String>? tagNames,
/// What Explore shows as its description; null to leave it to the /// What Explore shows as its description; null to leave it to the
/// studyset's own. /// studyset's own.
final RichContent? description, final RichContent? description,
}) extends PublishEvent { }) extends PublishEvent {
@override @override
List<Object?> get props => [slug, notes, subtree, description]; List<Object?> get props => [slug, notes, subtree, tagNames, description];
} }

View file

@ -63,19 +63,29 @@ class PublishRepository(
/// Publishes the studyset [studysetId] as its next release, [notes] /// Publishes the studyset [studysetId] as its next release, [notes]
/// saying what changed; at the first, under the name [slug]. Explore /// saying what changed; at the first, under the name [slug]. Explore
/// shows [description] as its description, when given. Throws /// shows [description] as its description, when given. Throws
/// [PublishRefused] when the instance refuses. /// [PublishRefused] when the instance refuses, or here with
/// [PublishProblem.tagNamesChanged] when the names of the tags it would
/// send aren't [tagNamesShown], what the person saw ([tagNamesOf]; null
/// where nothing was shown): the sync before publishing may have merged
/// a link to another tag in, whose name nobody saw going (the final
/// reading of 22 Oct).
Future<Publication> publish( Future<Publication> publish(
String studysetId, { String studysetId, {
required String slug, required String slug,
required String notes, required String notes,
required Map<String, String>? tagNamesShown,
RichContent? description, RichContent? description,
}) async { }) async {
final names = await _tagNames?.call(studysetId);
if (names != null && !mapEquals(names, tagNamesShown ?? const {})) {
throw PublishRefused(PublishProblem.tagNamesChanged);
}
final publication = await _gateway.publish( final publication = await _gateway.publish(
studysetId, studysetId,
slug: slug.trim(), slug: slug.trim(),
notes: notes.trim(), notes: notes.trim(),
description: description, description: description,
tagNames: await _tagNames?.call(studysetId), tagNames: names,
); );
await _rememberOne(studysetId, out: true); await _rememberOne(studysetId, out: true);
return publication; return publication;

View file

@ -131,6 +131,11 @@ enum PublishProblem() {
/// The release's tags couldn't all be named: the person's tags are /// The release's tags couldn't all be named: the person's tags are
/// encrypted, and this device didn't name one (ADR 0019, D2). /// encrypted, and this device didn't name one (ADR 0019, D2).
tagNamesMismatch, tagNamesMismatch,
/// The names of the tags it would send changed since the sheet showed
/// them (a sync merged a tag in): refused here, before any went out (the
/// final reading of 22 Oct).
tagNamesChanged,
} }
/// The instance refused to publish, for [problem]; [items] names the /// The instance refused to publish, for [problem]; [items] names the

View file

@ -79,6 +79,14 @@ class _PublishSheetState() extends State<_PublishSheet> {
); );
final _notes = TextEditingController(); final _notes = TextEditingController();
/// The names of the tags the release would send, as read for the sheet;
/// read again after they changed.
Future<Map<String, String>>? _tagNames;
/// Those names as the person saw them: what Publish sends, and nothing
/// else (the final reading of 22 Oct).
Map<String, String>? _tagNamesShown;
/// What Explore shows as its description, once changed here; until then /// What Explore shows as its description, once changed here; until then
/// the latest release's, else the studyset's own ([_aboutIn]). /// the latest release's, else the studyset's own ([_aboutIn]).
RichContent? _about; RichContent? _about;
@ -118,6 +126,7 @@ class _PublishSheetState() extends State<_PublishSheet> {
slug: _slug.text, slug: _slug.text,
notes: _notes.text, notes: _notes.text,
subtree: subtreeOf(widget.studyset.id, widget.all), subtree: subtreeOf(widget.studyset.id, widget.all),
tagNames: _tagNamesShown,
description: _aboutIn(bloc.state), description: _aboutIn(bloc.state),
), ),
); );
@ -128,8 +137,21 @@ class _PublishSheetState() extends State<_PublishSheet> {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
return BlocConsumer<PublishBloc, PublishState>( return BlocConsumer<PublishBloc, PublishState>(
listenWhen: (before, now) => listenWhen: (before, now) =>
now is PublishStateReady && now.outcome != null, now is PublishStateReady &&
(now.outcome != null ||
(now.problem == PublishProblem.tagNamesChanged && before != now)),
listener: (context, state) { listener: (context, state) {
if (state case PublishStateReady(
problem: PublishProblem.tagNamesChanged,
outcome: null,
)) {
// Read again, for the person to see what would go now.
setState(() {
_tagNames = null;
_tagNamesShown = null;
});
return;
}
final message = switch (state) { final message = switch (state) {
PublishStateReady( PublishStateReady(
outcome: PublishOutcome.published, outcome: PublishOutcome.published,
@ -185,9 +207,13 @@ class _PublishSheetState() extends State<_PublishSheet> {
), ),
if (ready.profile != null) if (ready.profile != null)
PublishTagNames( PublishTagNames(
names: PersonDependencies.of( key: ValueKey(
context, _tagNames ??= PersonDependencies.of(
).publishing.tagNamesOf(widget.studyset.id), context,
).publishing.tagNamesOf(widget.studyset.id),
),
names: _tagNames!,
onShown: (names) => _tagNamesShown = names,
), ),
], ],
), ),
@ -623,6 +649,7 @@ String _problemText(
), ),
PublishProblem.private => l10n.publishPrivateRefused, PublishProblem.private => l10n.publishPrivateRefused,
PublishProblem.tagNamesMismatch => l10n.publishTagNames, PublishProblem.tagNamesMismatch => l10n.publishTagNames,
PublishProblem.tagNamesChanged => l10n.publishTagNamesChanged,
}; };
/// The names of the tags a release sends, which the instance learns, shown /// The names of the tags a release sends, which the instance learns, shown
@ -630,6 +657,9 @@ String _problemText(
/// that goes out; none while they can't be had. /// that goes out; none while they can't be had.
class const PublishTagNames({ class const PublishTagNames({
required final Future<Map<String, String>> names, required final Future<Map<String, String>> names,
/// Called with the names once they are shown.
final void Function(Map<String, String> names)? onShown,
super.key, super.key,
}) extends StatefulWidget { }) extends StatefulWidget {
@override @override
@ -651,6 +681,7 @@ class _PublishTagNamesState() extends State<PublishTagNames> {
final names = await widget.names; final names = await widget.names;
if (mounted) { if (mounted) {
setState(() => _sent = [...names.values]..sort()); setState(() => _sent = [...names.values]..sort());
widget.onShown?.call(names);
} }
} on Object { } on Object {
// Something in it is private: publishing is refused anyway. // Something in it is private: publishing is refused anyway.

View file

@ -1,11 +1,12 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/publish/data/publish_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/publish/model/publication.dart';
import '../../../support/fake_publish_gateway.dart'; import '../../../support/fake_publish_gateway.dart';
import '../../../support/memory_published_store.dart'; import '../../../support/memory_published_store.dart';
/// The instance can't read the person's tags (ADR 0019, D2): publishing /// The instance can't read the person's tags (ADR 0019, D2): publishing
/// names them as this device reads them. /// names them as this device reads them, exactly as the sheet showed them.
void main() { void main() {
test('publishing sends the names of the tags the release carries, as this ' test('publishing sends the names of the tags the release carries, as this '
'device reads them, and no others', () async { 'device reads them, and no others', () async {
@ -21,7 +22,12 @@ void main() {
); );
addTearDown(publishing.dispose); addTearDown(publishing.dispose);
await publishing.publish('studyset-1', slug: 'japanese', notes: ''); await publishing.publish(
'studyset-1',
slug: 'japanese',
notes: '',
tagNamesShown: {'tag-1': 'JLPT-N5'},
);
expect(gateway.lastTagNames, {'tag-1': 'JLPT-N5'}); expect(gateway.lastTagNames, {'tag-1': 'JLPT-N5'});
expect(asked, ['studyset-1']); expect(asked, ['studyset-1']);
@ -32,8 +38,67 @@ void main() {
final publishing = PublishRepository(gateway, MemoryPublishedStore()); final publishing = PublishRepository(gateway, MemoryPublishedStore());
addTearDown(publishing.dispose); addTearDown(publishing.dispose);
await publishing.publish('studyset-1', slug: 'japanese', notes: ''); await publishing.publish(
'studyset-1',
slug: 'japanese',
notes: '',
tagNamesShown: null,
);
expect(gateway.lastTagNames, isNull); expect(gateway.lastTagNames, isNull);
}); });
test('names that changed since the sheet showed them (a sync merged a tag '
'in) are never sent: publishing is refused here', () async {
final gateway = FakePublishGateway();
var names = {'tag-1': 'JLPT-N5'};
final publishing = PublishRepository(
gateway,
MemoryPublishedStore(),
tagNames: (studysetId) async => names,
);
addTearDown(publishing.dispose);
final shown = await publishing.tagNamesOf('studyset-1');
// The sync before publishing brings a link to a private tag.
names = {'tag-1': 'JLPT-N5', 'tag-2': 'あかね噺 S1 E1'};
await expectLater(
publishing.publish(
'studyset-1',
slug: 'japanese',
notes: '',
tagNamesShown: shown,
),
throwsA(
isA<PublishRefused>().having(
(e) => e.problem,
'problem',
PublishProblem.tagNamesChanged,
),
),
);
expect(gateway.lastTagNames, isNull, reason: 'nothing went out');
});
test('names never shown are never sent', () async {
final gateway = FakePublishGateway();
final publishing = PublishRepository(
gateway,
MemoryPublishedStore(),
tagNames: (studysetId) async => {'tag-1': 'JLPT-N5'},
);
addTearDown(publishing.dispose);
await expectLater(
publishing.publish(
'studyset-1',
slug: 'japanese',
notes: '',
tagNamesShown: null,
),
throwsA(isA<PublishRefused>()),
);
expect(gateway.lastTagNames, isNull);
});
} }

View file

@ -63,6 +63,7 @@ void main() {
slug: 'japanese', slug: 'japanese',
notes: 'First lessons.', notes: 'First lessons.',
subtree: ['japanese'], subtree: ['japanese'],
tagNames: null,
), ),
); );
return await bloc.stream.firstWhere( return await bloc.stream.firstWhere(
@ -165,7 +166,12 @@ void main() {
publishing.fail = Exception('offline'); publishing.fail = Exception('offline');
publisher.add( publisher.add(
const PublishEventRequested(slug: 'japanese', notes: '', subtree: []), const PublishEventRequested(
slug: 'japanese',
notes: '',
subtree: [],
tagNames: null,
),
); );
final failed = await publisher.stream.firstWhere( final failed = await publisher.stream.firstWhere(
(state) => state is PublishStateReady && !state.publishing, (state) => state is PublishStateReady && !state.publishing,

View file

@ -46,7 +46,12 @@ void main() {
await pumpEventQueue(); await pumpEventQueue();
await publishing.publication('japanese'); await publishing.publication('japanese');
await publishing.publish('kanji', slug: 'kanji', notes: ''); await publishing.publish(
'kanji',
slug: 'kanji',
notes: '',
tagNamesShown: null,
);
await publishing.withdraw('japanese'); await publishing.withdraw('japanese');
await pumpEventQueue(); await pumpEventQueue();

View file

@ -191,6 +191,7 @@ void main() {
root, root,
slug: 'japanese', slug: 'japanese',
notes: 'The first release.', notes: 'The first release.',
tagNamesShown: null,
); );
expect(first.latest?.version, 1); expect(first.latest?.version, 1);
expect(first.latest?.cards, 3); expect(first.latest?.cards, 3);
@ -243,6 +244,7 @@ void main() {
root, root,
slug: 'japanese', slug: 'japanese',
notes: 'An inu, and a bird.', notes: 'An inu, and a bird.',
tagNamesShown: null,
); );
expect(second.latest?.version, 2); expect(second.latest?.version, 2);
@ -269,7 +271,12 @@ void main() {
// holds the second, is offered nothing. // holds the second, is offered nothing.
await laptop.cards.create(root, card('魚', 'fish')); await laptop.cards.create(root, card('魚', 'fish'));
await laptop.sync(); await laptop.sync();
await publishing.publish(root, slug: 'japanese', notes: 'A fish.'); await publishing.publish(
root,
slug: 'japanese',
notes: 'A fish.',
tagNamesShown: null,
);
expect((await explore.updateOf(copy))?.version, 3); expect((await explore.updateOf(copy))?.version, 3);
final withdrawn = await publishing.withdraw(root); final withdrawn = await publishing.withdraw(root);
expect(withdrawn.withdrawn, isTrue); expect(withdrawn.withdrawn, isTrue);