fix(explore): copies keep their ties to PDF pages, and ids swap whole
A copy's cards, notes and screenshots tied to a page of one of its PDFs kept the author's PDF id, so the tie led nowhere in every copy from Explore and every archive imported. The copier now writes the PDFs first and points what is tied to them at the PDFs it made; an update does the same, writing a release's new PDFs before the cards tied to them, so a card the release changes keeps its tie. The files a copy names were swapped by replacing each author's id in the payload's text, one after another: an id that was another's new one swapped twice, and an id written in the text changed too. Now only whole values are swapped (withOwnIds): a block's asset URL, a PDF's file, a study button's or an exam plan's studysets, a page's PDF. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
39926ddc5b
commit
197726f5a3
6 changed files with 284 additions and 103 deletions
|
|
@ -191,6 +191,18 @@ String? assetIdOf(Object? url) =>
|
|||
|
||||
const _assetScheme = 'asset:';
|
||||
|
||||
/// The person's files [json] (a payload's JSON, or a part of it) names: the
|
||||
/// `url` of every block that shows or plays one, as [RichContent.assetIds]
|
||||
/// finds them, at any depth.
|
||||
Set<String> assetIdsIn(Object? json) => switch (json) {
|
||||
final Map<String, Object?> map => {
|
||||
?assetIdOf(map['url']),
|
||||
for (final value in map.values) ...assetIdsIn(value),
|
||||
},
|
||||
final List<Object?> list => {for (final value in list) ...assetIdsIn(value)},
|
||||
_ => const {},
|
||||
};
|
||||
|
||||
/// The attribute holding the text of a block that keeps it beside its
|
||||
/// blocks, by the block's type.
|
||||
const _textKeys = {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import 'package:halkyon_learn_flutter/src/feature/card/data/card_payload.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_copier.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/data/release_entries.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/explore/model/explore_studyset.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_payload.dart';
|
||||
|
|
@ -138,13 +139,24 @@ class CopyUpdater(
|
|||
}
|
||||
}
|
||||
}
|
||||
Map<String, Object?> owned(Map<String, Object?> payload) {
|
||||
var text = jsonEncode(payload);
|
||||
for (final MapEntry(key: author, value: person) in own.entries) {
|
||||
text = text.replaceAll(author, person);
|
||||
}
|
||||
return jsonDecode(text) as Map<String, Object?>;
|
||||
}
|
||||
// A payload of the release naming the copy's own studysets, PDFs and
|
||||
// files, as the copier wrote the copy; those the update brings as soon
|
||||
// as they are made.
|
||||
Map<String, Object?> owned(Map<String, Object?> payload) =>
|
||||
withOwnIds(
|
||||
payload,
|
||||
studysets: {
|
||||
for (final MapEntry(key: item, value: studyset)
|
||||
in copy.studysets.entries)
|
||||
item: studyset.id,
|
||||
},
|
||||
pdfs: {
|
||||
for (final MapEntry(key: item, value: pdf) in copy.pdfs.entries)
|
||||
item: pdf.id,
|
||||
},
|
||||
assets: own,
|
||||
)!
|
||||
as Map<String, Object?>;
|
||||
|
||||
var toSettle = 0;
|
||||
void count(UpdateTaken taken) {
|
||||
|
|
@ -266,6 +278,39 @@ class CopyUpdater(
|
|||
}
|
||||
}
|
||||
|
||||
// PDFs, before what is tied to their pages.
|
||||
for (final entry in diff.changed.where(_is(ReleaseKind.pdf))) {
|
||||
final pdf = copy.pdfs[entry.id]!;
|
||||
await _pdfs.update(
|
||||
pdf.id,
|
||||
_pdfWithOrigin(
|
||||
decodePdfSourcePayload(
|
||||
_bytes(
|
||||
_threeWay(
|
||||
owned(diff.base[entry.id]!.payload),
|
||||
owned(entry.payload),
|
||||
_json(encodePdfSourcePayload(pdf.content)),
|
||||
),
|
||||
),
|
||||
),
|
||||
pdf.content.published!.taking(version),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final entry
|
||||
in diff.added.where(_is(ReleaseKind.pdf)).toList()..sort(_byPosition)) {
|
||||
final studysetId = copy.studysets[entry.container]!.id;
|
||||
final content = _pdfWithOrigin(
|
||||
decodePdfSourcePayload(_bytes(owned(entry.payload))),
|
||||
_origin(newer, entry.id),
|
||||
);
|
||||
copy.pdfs[entry.id] = PdfSource(
|
||||
id: await _pdfs.add(studysetId, content),
|
||||
studysetId: studysetId,
|
||||
content: content,
|
||||
);
|
||||
}
|
||||
|
||||
// Cards.
|
||||
for (final entry in diff.changed.where(_is(ReleaseKind.card))) {
|
||||
final card = copy.cards[entry.id]!;
|
||||
|
|
@ -420,36 +465,6 @@ class CopyUpdater(
|
|||
);
|
||||
}
|
||||
|
||||
// PDFs.
|
||||
for (final entry in diff.changed.where(_is(ReleaseKind.pdf))) {
|
||||
final pdf = copy.pdfs[entry.id]!;
|
||||
await _pdfs.update(
|
||||
pdf.id,
|
||||
_pdfWithOrigin(
|
||||
decodePdfSourcePayload(
|
||||
_bytes(
|
||||
_threeWay(
|
||||
owned(diff.base[entry.id]!.payload),
|
||||
owned(entry.payload),
|
||||
_json(encodePdfSourcePayload(pdf.content)),
|
||||
),
|
||||
),
|
||||
),
|
||||
pdf.content.published!.taking(version),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (final entry
|
||||
in diff.added.where(_is(ReleaseKind.pdf)).toList()..sort(_byPosition)) {
|
||||
await _pdfs.add(
|
||||
copy.studysets[entry.container]!.id,
|
||||
_pdfWithOrigin(
|
||||
decodePdfSourcePayload(_bytes(owned(entry.payload))),
|
||||
_origin(newer, entry.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// What the author withdrew stays the learner's, marked.
|
||||
for (final entry in diff.withdrawn) {
|
||||
switch (entry.kind) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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/core/content/rich_content.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';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
|
|
@ -79,8 +80,8 @@ class ReleaseCopier(
|
|||
/// Writes [release]'s items into the library as the person's own, linked
|
||||
/// to no publication: an archive's, as a backup or a move brings them
|
||||
/// (`feature/archive/`). Returns the top studyset and, by each item's id
|
||||
/// in the release, the id it has now (the archive's reviews name cards by
|
||||
/// theirs). Otherwise as [copy].
|
||||
/// in the release, the id it has now (an archive's progress names its
|
||||
/// items by theirs). Otherwise as [copy].
|
||||
/// Its cards say they came from [cardOrigin] ("archive"), as imported
|
||||
/// ones do.
|
||||
Future<({String top, Map<String, String> ids})> write(
|
||||
|
|
@ -167,8 +168,23 @@ class ReleaseCopier(
|
|||
step();
|
||||
}
|
||||
|
||||
// The PDFs before what is tied to their pages.
|
||||
final pdfIds = <String, String>{};
|
||||
for (final directory in read.directories) {
|
||||
for (final pdf in _ordered(read.pdfs[directory])) {
|
||||
pdfIds[pdf.id] = ids[pdf.id] = await _pdfs.add(
|
||||
studysetIds[directory]!,
|
||||
origin == null
|
||||
? pdf.content
|
||||
: _pdfWithOrigin(pdf.content, origin(pdf.id)),
|
||||
);
|
||||
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.
|
||||
// one made of it now, as it names the person's own files; content tied
|
||||
// to a PDF's page (a card, a note, a screenshot) the PDF made of it.
|
||||
final named = _read(
|
||||
release,
|
||||
assetIds,
|
||||
|
|
@ -177,6 +193,7 @@ class ReleaseCopier(
|
|||
in read.studysets.entries)
|
||||
item.id: studysetIds[directory]!,
|
||||
},
|
||||
pdfIds: pdfIds,
|
||||
);
|
||||
|
||||
// An exam plan's topics are studysets of the release too: its plans
|
||||
|
|
@ -249,7 +266,7 @@ class ReleaseCopier(
|
|||
}
|
||||
step();
|
||||
for (final stepItem in _ordered(named.steps[path.id])) {
|
||||
await _paths.createStep(
|
||||
ids[stepItem.id] = (await _paths.createStep(
|
||||
pathId,
|
||||
stepItem.kind!,
|
||||
origin == null
|
||||
|
|
@ -258,33 +275,24 @@ class ReleaseCopier(
|
|||
StepField.published,
|
||||
origin(stepItem.id),
|
||||
),
|
||||
);
|
||||
)).stepId;
|
||||
step();
|
||||
}
|
||||
}
|
||||
|
||||
for (final pdf in _ordered(named.pdfs[directory])) {
|
||||
await _pdfs.add(
|
||||
studysetId,
|
||||
origin == null
|
||||
? pdf.content
|
||||
: _pdfWithOrigin(pdf.content, origin(pdf.id)),
|
||||
);
|
||||
step();
|
||||
}
|
||||
}
|
||||
return (top: studysetIds['']!, ids: ids);
|
||||
}
|
||||
|
||||
/// [release]'s items decoded, each file of the release named by the
|
||||
/// 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.
|
||||
/// asset row [assetIds] gives its old one, each studyset of it a study
|
||||
/// button or an exam plan names by the one [studysetIds] gives, and each
|
||||
/// PDF a page is tied to by the one [pdfIds] gives; throws
|
||||
/// [ReleaseUnreadable] for an item this app can't read.
|
||||
_Read _read(
|
||||
PublishedRelease release,
|
||||
Map<String, String> assetIds, {
|
||||
Map<String, String> studysetIds = const {},
|
||||
Map<String, String> pdfIds = const {},
|
||||
}) {
|
||||
final read = _Read();
|
||||
try {
|
||||
|
|
@ -307,11 +315,18 @@ class ReleaseCopier(
|
|||
tag! as String,
|
||||
];
|
||||
// The person's own files in place of the author's.
|
||||
var text = jsonEncode(_renamed(json['payload'], studysetIds));
|
||||
for (final MapEntry(key: old, value: own) in assetIds.entries) {
|
||||
text = text.replaceAll(old, own);
|
||||
}
|
||||
final payload = ByteData.sublistView(utf8.encode(text));
|
||||
final payload = ByteData.sublistView(
|
||||
utf8.encode(
|
||||
jsonEncode(
|
||||
withOwnIds(
|
||||
json['payload'],
|
||||
studysets: studysetIds,
|
||||
pdfs: pdfIds,
|
||||
assets: assetIds,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
switch (kindAt < 0 ? 'studyset.json' : segments[kindAt]) {
|
||||
case 'studyset.json' when segments.last == 'studyset.json':
|
||||
read.studysets[directory] = _Item(
|
||||
|
|
@ -386,30 +401,6 @@ class ReleaseCopier(
|
|||
|
||||
static const _kinds = {'cards', 'notes', 'paths', 'pdfs'};
|
||||
|
||||
/// [json] with every studyset a study button or an exam plan 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,
|
||||
// The topics an exam plan covers.
|
||||
('topics', final List<Object?> ids) => [
|
||||
for (final id in ids)
|
||||
if (id is String) names[id] ?? id else id,
|
||||
],
|
||||
_ => _renamed(value, names),
|
||||
},
|
||||
},
|
||||
_ => json,
|
||||
};
|
||||
|
||||
/// The directory of the studyset [directory] is in.
|
||||
static String _parentOf(String directory) => switch (directory.lastIndexOf(
|
||||
'/',
|
||||
|
|
@ -471,6 +462,51 @@ class ReleaseCopier(
|
|||
);
|
||||
}
|
||||
|
||||
/// A release's [json] (an item's payload, or a part of it) with the ids of
|
||||
/// what it names swapped for the person's own: every studyset a study
|
||||
/// button or an exam plan names by the one [studysets] gives, every PDF a
|
||||
/// page is tied to (a card's or note's `source`, a screenshot's attribute:
|
||||
/// both `pdf`) by the one [pdfs] gives, and every file a block shows or
|
||||
/// plays (its `url`, `asset:<id>`) or a PDF is (its `asset`) by the one
|
||||
/// [assets] gives. Only whole values are swapped, so an id in the text
|
||||
/// stays as written.
|
||||
Object? withOwnIds(
|
||||
Object? json, {
|
||||
required Map<String, String> studysets,
|
||||
required Map<String, String> pdfs,
|
||||
required Map<String, String> assets,
|
||||
}) => switch (json) {
|
||||
_ when studysets.isEmpty && pdfs.isEmpty && assets.isEmpty => json,
|
||||
final List<Object?> list => [
|
||||
for (final item in list)
|
||||
withOwnIds(item, studysets: studysets, pdfs: pdfs, assets: assets),
|
||||
],
|
||||
final Map<String, Object?> map => {
|
||||
for (final MapEntry(:key, :value) in map.entries)
|
||||
key: switch ((key, value)) {
|
||||
(studyButtonStudysetKey, final String id) => studysets[id] ?? id,
|
||||
// The topics an exam plan covers.
|
||||
('topics', final List<Object?> ids) => [
|
||||
for (final id in ids)
|
||||
if (id is String) studysets[id] ?? id else id,
|
||||
],
|
||||
(imagePdfKey, final String id) => pdfs[id] ?? id,
|
||||
('url', final String url) => switch (assets[assetIdOf(url)]) {
|
||||
final own? => assetUrl(own),
|
||||
null => url,
|
||||
},
|
||||
('asset', final String id) => assets[id] ?? id,
|
||||
_ => withOwnIds(
|
||||
value,
|
||||
studysets: studysets,
|
||||
pdfs: pdfs,
|
||||
assets: assets,
|
||||
),
|
||||
},
|
||||
},
|
||||
_ => json,
|
||||
};
|
||||
|
||||
/// A release's items decoded, by the directory of the studyset they are in
|
||||
/// (`''` for the top one, `<id>/<id>` further in); steps by their path.
|
||||
final class _Read() {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ class FeedbackRepository(
|
|||
}
|
||||
// The files its content names, for the author to take into theirs.
|
||||
final files = <FeedbackFile>[];
|
||||
for (final assetId in _assetIdsIn(proposed)) {
|
||||
for (final assetId in assetIdsIn(proposed)) {
|
||||
if (await _assets.fileOf(assetId) case final file?) {
|
||||
files.add(
|
||||
FeedbackFile(
|
||||
|
|
@ -256,14 +256,3 @@ class FeedbackRepository(
|
|||
jsonDecode(utf8.decode(Uint8List.sublistView(payload)))
|
||||
as Map<String, Object?>;
|
||||
}
|
||||
|
||||
/// The person's files [json] (content's JSON) names: the `url` of every
|
||||
/// block that shows or plays one, as [RichContent.assetIds] finds them.
|
||||
Set<String> _assetIdsIn(Object? json) => switch (json) {
|
||||
final Map<String, Object?> map => {
|
||||
?assetIdOf(map['url']),
|
||||
for (final value in map.values) ..._assetIdsIn(value),
|
||||
},
|
||||
final List<Object?> list => {for (final value in list) ..._assetIdsIn(value)},
|
||||
_ => const {},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -542,4 +542,70 @@ void main() {
|
|||
['Animals', 'JLPT-N5', 'Noun'],
|
||||
);
|
||||
});
|
||||
|
||||
test("what is tied to a page of a PDF stays tied to the copy's PDF when "
|
||||
'the release changes it, and what a release ties to a PDF it brings '
|
||||
'is tied to the one made of it', () async {
|
||||
PublishedRelease release(int version) {
|
||||
final base = japanese(version);
|
||||
Map<String, Object?> page(String id, String back, String pdf) =>
|
||||
sortedKeys({
|
||||
'id': id,
|
||||
'position': 'a5',
|
||||
'tags': const <String>[],
|
||||
'payload': json(
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent.plain(id),
|
||||
back: RichContent.plain(back),
|
||||
source: (pdfId: pdf, page: 3),
|
||||
),
|
||||
),
|
||||
),
|
||||
});
|
||||
return PublishedRelease(
|
||||
publicId: base.publicId,
|
||||
version: version,
|
||||
items: {
|
||||
...base.items,
|
||||
'cards/page.json': page(
|
||||
'page',
|
||||
version == 1 ? 'is' : 'is, politely',
|
||||
'book',
|
||||
),
|
||||
if (version > 1) ...{
|
||||
'pdfs/workbook.json': sortedKeys({
|
||||
'id': 'workbook',
|
||||
'position': 'a1',
|
||||
'payload': json(
|
||||
encodePdfSourcePayload(
|
||||
const PdfSourceContent(
|
||||
title: 'Workbook',
|
||||
fileName: 'workbook.pdf',
|
||||
assetId: bookAsset,
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
'cards/drill.json': page('drill', 'a drill', 'workbook'),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final v1 = release(1);
|
||||
final root = await copyOf(v1);
|
||||
await updater.take(root, v1, release(2), fileOf: fileOf);
|
||||
|
||||
final copied = await pdfs.sourcesOf(root);
|
||||
String pdfTitled(String title) =>
|
||||
copied.singleWhere((pdf) => pdf.content.title == title).id;
|
||||
final page = await cardFrom(root, 'page');
|
||||
expect(page.content.back.plainText, 'is, politely');
|
||||
expect(page.content.source, (pdfId: pdfTitled('A book'), page: 3));
|
||||
expect(
|
||||
(await cardFrom(root, 'drill')).content.source,
|
||||
(pdfId: pdfTitled('Workbook'), page: 3),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,22 @@ void main() {
|
|||
),
|
||||
'kanji/cards/pictured.json': item(
|
||||
'pictured',
|
||||
encodeCardPayload(card('asset:$imageAsset', '絵')),
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent({
|
||||
'document': {
|
||||
'type': 'page',
|
||||
'children': [
|
||||
{
|
||||
'type': 'image',
|
||||
'data': {'url': assetUrl(imageAsset)},
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
back: RichContent.plain('絵'),
|
||||
),
|
||||
),
|
||||
),
|
||||
'notes/particles.json': item(
|
||||
'particles',
|
||||
|
|
@ -318,10 +333,8 @@ void main() {
|
|||
(studyset) => studyset.parentId == id,
|
||||
);
|
||||
final pictured = (await cards.cardsOf(kanji.id)).single;
|
||||
final front = pictured.content.front.plainText;
|
||||
expect(front, startsWith('asset:'));
|
||||
expect(front, isNot(contains(imageAsset)));
|
||||
final assetId = front.substring('asset:'.length);
|
||||
final assetId = pictured.content.front.assetIds.single;
|
||||
expect(assetId, isNot(imageAsset));
|
||||
expect((await assets.fileOf(assetId))!.bytes, image);
|
||||
final pdf = (await pdfs.sourcesOf(kanji.id)).single;
|
||||
expect(pdf.content.title, 'A book');
|
||||
|
|
@ -330,6 +343,56 @@ void main() {
|
|||
expect((await assets.fileOf(pdf.content.assetId))!.bytes, book);
|
||||
});
|
||||
|
||||
test("a card or note tied to a page of the release's PDF is tied to the "
|
||||
'PDF made of it', () async {
|
||||
final release = japanese();
|
||||
final id = await copier.copy(
|
||||
PublishedRelease(
|
||||
publicId: release.publicId,
|
||||
version: release.version,
|
||||
items: {
|
||||
...release.items,
|
||||
'kanji/cards/page.json': item(
|
||||
'page',
|
||||
encodeCardPayload(
|
||||
CardContent(
|
||||
front: RichContent.plain('本'),
|
||||
back: RichContent.plain('book'),
|
||||
source: (pdfId: 'book', page: 2),
|
||||
),
|
||||
),
|
||||
position: 'a5',
|
||||
),
|
||||
'kanji/notes/reading.json': item(
|
||||
'reading',
|
||||
encodeNotePayload(
|
||||
NoteContent(
|
||||
body: RichContent.plain('Chapter 1'),
|
||||
source: (pdfId: 'book', page: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
files: release.files,
|
||||
),
|
||||
location: 'halkyon.eu/@alice/japanese',
|
||||
fileOf: fileOf,
|
||||
);
|
||||
|
||||
final kanji = (await studysets.all()).singleWhere(
|
||||
(studyset) => studyset.parentId == id,
|
||||
);
|
||||
final pdf = (await pdfs.sourcesOf(kanji.id)).single;
|
||||
final page = (await cards.cardsOf(
|
||||
kanji.id,
|
||||
)).singleWhere((card) => card.content.front.plainText == '本');
|
||||
expect(page.content.source, (pdfId: pdf.id, page: 2));
|
||||
expect(
|
||||
(await notes.notesOf(kanji.id)).single.content.source,
|
||||
(pdfId: pdf.id, page: 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('notes and learning paths, steps in their order', () async {
|
||||
final id = await copier.copy(
|
||||
japanese(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue