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:
Tizian 「ティツィアーン」 2026-10-15 18:56:13 +02:00
commit 197726f5a3
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
2 changed files with 284 additions and 103 deletions

View file

@ -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),
);
});
}

View file

@ -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(),