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.",
"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",
"studysetMenuMakeReadable": "Nicht mehr privat",
"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.",
"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",
"studysetMenuMakeReadable": "Make non-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.'**
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.
///
/// In en, this message translates to:

View file

@ -7719,6 +7719,10 @@ class AppLocalizationsDe extends AppLocalizations {
String get publishTagNames =>
'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
String get studysetMenuMakePrivate => 'Privat machen';

View file

@ -7646,6 +7646,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get publishTagNames =>
'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
String get studysetMenuMakePrivate => 'Make private';

View file

@ -87,6 +87,7 @@ class PublishBloc(
slug: event.slug,
notes: event.notes,
description: event.description,
tagNamesShown: event.tagNames,
);
}
@ -300,10 +301,15 @@ final class const PublishEventRequested({
required final String notes,
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
/// studyset's own.
final RichContent? description,
}) extends PublishEvent {
@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]
/// saying what changed; at the first, under the name [slug]. Explore
/// 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(
String studysetId, {
required String slug,
required String notes,
required Map<String, String>? tagNamesShown,
RichContent? description,
}) async {
final names = await _tagNames?.call(studysetId);
if (names != null && !mapEquals(names, tagNamesShown ?? const {})) {
throw PublishRefused(PublishProblem.tagNamesChanged);
}
final publication = await _gateway.publish(
studysetId,
slug: slug.trim(),
notes: notes.trim(),
description: description,
tagNames: await _tagNames?.call(studysetId),
tagNames: names,
);
await _rememberOne(studysetId, out: true);
return publication;

View file

@ -131,6 +131,11 @@ enum PublishProblem() {
/// 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).
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

View file

@ -79,6 +79,14 @@ class _PublishSheetState() extends State<_PublishSheet> {
);
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
/// the latest release's, else the studyset's own ([_aboutIn]).
RichContent? _about;
@ -118,6 +126,7 @@ class _PublishSheetState() extends State<_PublishSheet> {
slug: _slug.text,
notes: _notes.text,
subtree: subtreeOf(widget.studyset.id, widget.all),
tagNames: _tagNamesShown,
description: _aboutIn(bloc.state),
),
);
@ -128,8 +137,21 @@ class _PublishSheetState() extends State<_PublishSheet> {
final l10n = AppLocalizations.of(context);
return BlocConsumer<PublishBloc, PublishState>(
listenWhen: (before, now) =>
now is PublishStateReady && now.outcome != null,
now is PublishStateReady &&
(now.outcome != null ||
(now.problem == PublishProblem.tagNamesChanged && before != now)),
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) {
PublishStateReady(
outcome: PublishOutcome.published,
@ -185,9 +207,13 @@ class _PublishSheetState() extends State<_PublishSheet> {
),
if (ready.profile != null)
PublishTagNames(
names: PersonDependencies.of(
context,
).publishing.tagNamesOf(widget.studyset.id),
key: ValueKey(
_tagNames ??= PersonDependencies.of(
context,
).publishing.tagNamesOf(widget.studyset.id),
),
names: _tagNames!,
onShown: (names) => _tagNamesShown = names,
),
],
),
@ -623,6 +649,7 @@ String _problemText(
),
PublishProblem.private => l10n.publishPrivateRefused,
PublishProblem.tagNamesMismatch => l10n.publishTagNames,
PublishProblem.tagNamesChanged => l10n.publishTagNamesChanged,
};
/// 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.
class const PublishTagNames({
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,
}) extends StatefulWidget {
@override
@ -651,6 +681,7 @@ class _PublishTagNamesState() extends State<PublishTagNames> {
final names = await widget.names;
if (mounted) {
setState(() => _sent = [...names.values]..sort());
widget.onShown?.call(names);
}
} on Object {
// Something in it is private: publishing is refused anyway.

View file

@ -1,11 +1,12 @@
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/model/publication.dart';
import '../../../support/fake_publish_gateway.dart';
import '../../../support/memory_published_store.dart';
/// 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() {
test('publishing sends the names of the tags the release carries, as this '
'device reads them, and no others', () async {
@ -21,7 +22,12 @@ void main() {
);
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(asked, ['studyset-1']);
@ -32,8 +38,67 @@ void main() {
final publishing = PublishRepository(gateway, MemoryPublishedStore());
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);
});
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',
notes: 'First lessons.',
subtree: ['japanese'],
tagNames: null,
),
);
return await bloc.stream.firstWhere(
@ -165,7 +166,12 @@ void main() {
publishing.fail = Exception('offline');
publisher.add(
const PublishEventRequested(slug: 'japanese', notes: '', subtree: []),
const PublishEventRequested(
slug: 'japanese',
notes: '',
subtree: [],
tagNames: null,
),
);
final failed = await publisher.stream.firstWhere(
(state) => state is PublishStateReady && !state.publishing,

View file

@ -46,7 +46,12 @@ void main() {
await pumpEventQueue();
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 pumpEventQueue();

View file

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