fix(explore): a study button in a copy studies the copy's studyset
All checks were successful
CI / check (push) Successful in 15m38s
CI / deploy (push) Successful in 1m20s

A study button names the studyset it studies by its id. Copied from a
release or an archive, the studysets get new ids, and a button in the
content kept naming the author's (or the archive's), which the person
doesn't have: it studied nothing. The copier now writes the content after
the studysets, each study button naming the studyset made of the one it
named; only a study button's studyset, as a short id could also be a word
of the content.

A later release taken into a copy still brings the author's ids into its
buttons: the copy updater doesn't rename them yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MMm7usAo6q9ZZRoPkWjoxi
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-11 16:50:26 +02:00
commit 0d4f7a432d
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 107 additions and 11 deletions

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:typed_data';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/published_ref.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
@ -166,14 +167,26 @@ class ReleaseCopier(
step();
}
// Content naming a studyset of the release (a study button) names the
// one made of it now, as it names the person's own files.
final named = _read(
release,
assetIds,
studysetIds: {
for (final MapEntry(key: directory, value: item)
in read.studysets.entries)
item.id: studysetIds[directory]!,
},
);
final tagIds = <String, String>{};
Future<Set<String>> tagsOf(List<String> names) async => {
for (final name in names) tagIds[name] ??= await _tags.create(name),
};
for (final directory in read.directories) {
for (final directory in named.directories) {
final studysetId = studysetIds[directory]!;
final cards = _ordered(read.cards[directory]);
final cards = _ordered(named.cards[directory]);
final cardIds = await _cards.createAll(studysetId, [
for (final card in cards)
if (origin == null)
@ -191,7 +204,7 @@ class ReleaseCopier(
done += cards.length;
onProgress?.call(done / total);
for (final note in _ordered(read.notes[directory])) {
for (final note in _ordered(named.notes[directory])) {
final (:noteId, revisionId: _) = await _notes.create(
studysetId,
origin == null
@ -205,7 +218,7 @@ class ReleaseCopier(
step();
}
for (final path in _ordered(read.paths[directory])) {
for (final path in _ordered(named.paths[directory])) {
final pathId = ids[path.id] = await _paths.createPath(
studysetId,
origin == null
@ -216,7 +229,7 @@ class ReleaseCopier(
await _tags.setTagsOfPath(pathId, await tagsOf(path.tags));
}
step();
for (final stepItem in _ordered(read.steps[path.id])) {
for (final stepItem in _ordered(named.steps[path.id])) {
await _paths.createStep(
pathId,
stepItem.kind!,
@ -231,7 +244,7 @@ class ReleaseCopier(
}
}
for (final pdf in _ordered(read.pdfs[directory])) {
for (final pdf in _ordered(named.pdfs[directory])) {
await _pdfs.add(
studysetId,
origin == null
@ -245,9 +258,15 @@ class ReleaseCopier(
}
/// [release]'s items decoded, each file of the release named by the
/// asset row [assetIds] gives its old one; throws [ReleaseUnreadable] for
/// an item this app can't read.
_Read _read(PublishedRelease release, Map<String, String> assetIds) {
/// asset row [assetIds] gives its old one, and each studyset of it a
/// study button names by the one [studysetIds] gives (the content's only
/// reference to a studyset); throws [ReleaseUnreadable] for an item this
/// app can't read.
_Read _read(
PublishedRelease release,
Map<String, String> assetIds, {
Map<String, String> studysetIds = const {},
}) {
final read = _Read();
try {
for (final MapEntry(key: path, value: json) in release.items.entries) {
@ -269,7 +288,7 @@ class ReleaseCopier(
tag! as String,
];
// The person's own files in place of the author's.
var text = jsonEncode(json['payload']);
var text = jsonEncode(_renamed(json['payload'], studysetIds));
for (final MapEntry(key: old, value: own) in assetIds.entries) {
text = text.replaceAll(old, own);
}
@ -348,6 +367,25 @@ class ReleaseCopier(
static const _kinds = {'cards', 'notes', 'paths', 'pdfs'};
/// [json] with every studyset a study button names by the one [names]
/// gives in its place.
static Object? _renamed(Object? json, Map<String, String> names) =>
names.isEmpty
? json
: switch (json) {
final List<Object?> list => [
for (final item in list) _renamed(item, names),
],
final Map<String, Object?> map => {
for (final MapEntry(:key, :value) in map.entries)
key: switch ((key, value)) {
(studyButtonStudysetKey, final String id) => names[id] ?? id,
_ => _renamed(value, names),
},
},
_ => json,
};
/// The directory of the studyset [directory] is in.
static String _parentOf(String directory) => switch (directory.lastIndexOf(
'/',

View file

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/content/content_blocks.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_importer.dart';
import 'package:halkyon_learn_flutter/src/feature/archive/data/archive_reader.dart';
@ -12,7 +13,12 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
import 'package:halkyon_learn_flutter/src/feature/dictionary/data/dictionary_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_step_payload.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/step_content.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/pdf_source_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/study/data/review_repository.dart';
@ -163,13 +169,14 @@ void main() {
late StudysetRepository studysets;
late TagRepository tags;
late ReviewRepository reviews;
late LearningPathRepository paths;
late ArchiveImporter importer;
setUp(() {
studysets = StudysetRepository(FakeStudysetGateway());
cards = CardRepository(FakeCardGateway());
final notes = NoteRepository(FakeNoteGateway());
final paths = LearningPathRepository(FakeLearningPathGateway());
paths = LearningPathRepository(FakeLearningPathGateway());
tags = TagRepository(
FakeTagGateway(),
cardChanges: cards.changes,
@ -203,6 +210,57 @@ void main() {
);
});
test('content naming a studyset of the archive, as a study button '
'does, names it as it is in the library', () async {
final archive = {
...japanese(),
'paths/drill/path.json': {
'id': 'drill',
'position': 'a0',
'payload': json(
encodeLearningPathPayload(LearningPathContent.titled('Drill')),
),
},
'paths/drill/steps/go.json': {
'id': 'go',
'position': 'a0',
'kind': StepKind.info.value,
'payload': json(
encodeStepPayload(
StepContent.empty(StepKind.info).withField(
StepField.body,
RichContent({
'document': {
'type': 'page',
'children': [
{
'type': studyButtonBlockType,
'data': {studyButtonStudysetKey: 'vocabulary'},
},
],
},
}),
),
),
),
},
};
await importer.import(readArchive(zip(archive)));
final vocabulary = (await studysets.all()).singleWhere(
(s) => s.content.title == 'Vocabulary',
);
final path = (await paths.pathsOf(
(await studysets.all()).singleWhere((s) => s.parentId == null).id,
)).single;
final step = (await paths.stepsOf(path.id)).single;
expect(
jsonEncode(step.content.body.toJson()),
contains('"$studyButtonStudysetKey":"${vocabulary.id}"'),
);
});
test("brings the studysets and cards in as the person's own, a word "
'from the dictionary as adding it makes it, the reviews onto the '
'cards they were of; an entry the instance lacks is left out and '