feat(learn): Markdown files beside the PDFs, read formatted

A studyset's files take Markdown (.md, .markdown) as well as PDFs, in
the same synced rows with a kind (payload schema 3, written only for
Markdown, so older apps skip those rows). Upload a PDF becomes Upload a
file; a Markdown file must be UTF-8 text.

The reader opens one formatted and read-only through the editor's
Markdown reader, keeping fenced code, which that reader drops; the file
itself never changes, and its source text is a tap away, selectable too.
It has neither search, zoom nor screenshots. It reads in 100 parts by
its top-level blocks, so reading progress, deadlines and the cards and
notes tied to a place work as a PDF's pages do, shown as a share; its
outermost headings are its chapters.

Whats-New: Upload Markdown files beside your PDFs: they open formatted, with reading progress, deadlines and flashcards from what you select.
Whats-New-De: Lade Markdown-Dateien neben deine PDFs hoch: Sie öffnen sich formatiert, mit Lesefortschritt, Fristen und Karteikarten aus deiner Auswahl.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-10 21:30:46 +01:00
commit 151f848360
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
7 changed files with 1460 additions and 195 deletions

View file

@ -387,6 +387,37 @@ void main() {
expect((await assets.fileOf(pdf.content.assetId))!.bytes, book);
});
test('a Markdown file is copied as Markdown (decided 10 Nov 2026)', () async {
final release = japanese();
final id = await copier.copy(
PublishedRelease(
publicId: release.publicId,
version: release.version,
items: {
...release.items,
'pdfs/grammar.json': item(
'grammar',
encodePdfSourcePayload(
const PdfSourceContent(
title: 'Grammar',
fileName: 'grammar.md',
assetId: bookAsset,
kind: FileKind.markdown,
),
),
),
},
files: release.files,
),
location: 'halkyon.eu/@alice/japanese',
fileOf: fileOf,
);
final grammar = (await pdfs.sourcesOf(id)).single;
expect(grammar.content.kind, FileKind.markdown);
expect(grammar.content.published?.item, 'grammar');
});
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();

View file

@ -0,0 +1,74 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/data/markdown_document.dart';
import 'package:halkyon_learn_flutter/src/feature/pdf_source/model/pdf_source.dart';
import 'package:material_ui/material_ui.dart';
void main() {
// Eleven blocks: a heading, a paragraph, two items of a list, a code
// block holding a `#` line, then another heading and five more.
const markdown = '''
# Kana
Hiragana first.
- あ
- い
```
# not a heading
```
# Particles
は marks the topic.
が marks the subject.
## Topic and subject
Both at once.
The end.
''';
test('reads in 100 parts, a part of each block by where it stands', () {
final document = MarkdownDocument(markdown);
expect(document.pageCount, markdownParts);
expect(document.blockCount, 11);
expect(document.partOf(0), 1);
expect(document.partOf(5), 46);
expect(document.partOf(10), 91);
expect(document.firstBlockOf(46), 5);
expect(document.firstBlockOf(47), 6);
expect(document.firstBlockOf(100), 10);
});
test('its chapters are its top-level headings, the outermost level it '
'has, not lines in code', () async {
expect(await MarkdownDocument(markdown).chapters(), [
(title: 'Kana', page: 1),
(title: 'Particles', page: 46),
]);
expect(
await MarkdownDocument(
'## Kana\n\nあ\n\n### Hiragana\n\n## Kanji\n',
).chapters(),
[(title: 'Kana', page: 1), (title: 'Kanji', page: 76)],
);
expect(await MarkdownDocument('Just text.').chapters(), isEmpty);
});
test("a part's text is that of its blocks, code as written; a part "
'without a block of its own has none', () async {
final document = MarkdownDocument(markdown);
expect(await document.pageText(1), 'Kana');
expect(await document.pageText(19), 'あ');
expect(await document.pageText(37), '# not a heading');
expect(await document.pageText(46), 'Particles');
expect(await document.pageText(2), isEmpty);
expect(await document.pageText(101), isEmpty);
expect(await document.renderRegion(1, Rect.zero), isNull);
});
}

View file

@ -85,9 +85,50 @@ void main() {
}
});
test('titles a PDF by its file name without .pdf', () {
test('titles a file by its name without .pdf, .md or .markdown', () {
expect(titleFromFileName('Genki I.PDF'), 'Genki I');
expect(titleFromFileName(' notes '), 'notes');
expect(titleFromFileName('Particles.md'), 'Particles');
expect(titleFromFileName('Verbs.Markdown'), 'Verbs');
});
test('a Markdown file survives a round trip in the third schema, which '
'older apps refuse, so they skip it; a PDF stays in the second, which '
'they read (decided 10 Nov 2026)', () {
const markdown = PdfSourceContent(
title: 'Particles',
fileName: 'Particles.md',
assetId: 'file-2',
kind: FileKind.markdown,
);
expect(decodePdfSourcePayload(encodePdfSourcePayload(markdown)), markdown);
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(encodePdfSourcePayload(markdown))),
);
expect(json, containsPair('schemaVersion', 3));
expect(json, containsPair('kind', 'markdown'));
final pdf = jsonDecode(
utf8.decode(Uint8List.sublistView(encodePdfSourcePayload(content))),
);
expect(pdf, containsPair('schemaVersion', 2));
expect(pdf, isNot(contains('kind')));
expect(
decodePdfSourcePayload(encodePdfSourcePayload(content)).kind,
FileKind.pdf,
);
expect(
() => decodePdfSourcePayload(
bytes({
'schemaVersion': 3,
'title': 'x',
'fileName': 'x.docx',
'asset': 'a',
'kind': 'docx',
}),
),
throwsFormatException,
);
});
test('counts days between calendar days, across months and years', () {

View file

@ -87,6 +87,53 @@ void main() {
expect(assetGateway.rows, isEmpty);
});
test('adds a Markdown file (.md, .markdown) as one, read by share: a '
'hundred parts (decided 10 Nov 2026)', () async {
final added = await pdfs.import(
'japanese',
bytes: Uint8List.fromList(utf8.encode('# Particles\n\nは and が')),
fileName: 'Particles.md',
);
final source = (await pdfs.sourcesOf('japanese')).single;
expect(added, PdfImported(source.id));
expect(source.content.title, 'Particles');
expect(source.content.kind, FileKind.markdown);
expect(source.content.pageCount, markdownParts);
expect(assetGateway.rows.single.mimeType, 'text/markdown');
expect(
await pdfs.import(
'japanese',
bytes: Uint8List.fromList(utf8.encode('Verbs')),
fileName: 'Verbs.MARKDOWN',
),
isA<PdfImported>(),
);
});
test(
'refuses a Markdown file that is no UTF-8 text, or over 20 MB',
() async {
expect(
await pdfs.import(
'japanese',
bytes: Uint8List.fromList([0xff, 0xfe, 0x00]),
fileName: 'broken.md',
),
const PdfRefused(PdfRefusal.notText),
);
expect(
await pdfs.import(
'japanese',
bytes: Uint8List(AssetRepository.maxBytes + 1),
fileName: 'huge.md',
),
const PdfRefused(PdfRefusal.textTooLarge),
);
expect(await pdfs.sourcesOf('japanese'), isEmpty);
},
);
test('says when the studyset holds the same file already, and adds it '
'again only when asked to', () async {
final first = await pdfs.import(

View file

@ -92,7 +92,7 @@ void main() {
files.next = (name: name, bytes: bytes);
await tester.tap(find.text('Add material'));
await tester.pumpAndSettle();
await choose(tester, 'Upload a PDF');
await choose(tester, 'Upload a file');
}
testWidgets('a PDF uploaded from Add material shows in Files, titled by '
@ -101,7 +101,7 @@ void main() {
await upload(tester, 'Grammar Guide.pdf', pdf('grammar'));
expect(files.asked.single, ['pdf']);
expect(files.asked.single, ['pdf', 'md', 'markdown']);
expect(find.text('Added "Grammar Guide".'), findsOneWidget);
expect(shownPart(tester), StudysetPart.material);
expect(find.text('Grammar Guide'), findsOneWidget);

View file

@ -447,6 +447,75 @@ void main() {
expect(find.textContaining('か', findRichText: true), findsOneWidget);
});
/// A Markdown file of two chapters, or of [paragraphs] paragraphs,
/// stored before the app starts, read to [readTo].
Future<String> storedMarkdown({int? paragraphs, int? readTo}) async {
final assets = AssetRepository(
assetGateway,
FakeAssetServerGateway(),
files,
FakeAssetSealing(),
);
final pdfs = PdfSourceRepository(pdfGateway, assets);
addTearDown(() async {
await pdfs.dispose();
await assets.dispose();
});
final id = (await pdfs.import(
'japanese',
bytes: Uint8List.fromList(
utf8.encode(
paragraphs == null
? '# Kana\n\nHiragana **first**.\n\n'
'```\n# a line of code\n```\n\n'
'# Particles\n\nは marks the topic.\n'
: [for (var i = 0; i < paragraphs; i++) 'Line $i.'].join('\n\n'),
),
),
fileName: 'Grammar.md',
) as PdfImported).id;
if (readTo != null) {
await pdfs.reached(id, readTo);
}
return id;
}
testWidgets('a Markdown file opens formatted, read in shares, without '
'search or zoom; its source text is a tap away', (tester) async {
final id = await storedMarkdown();
await read(tester, id);
expect(find.textContaining('first', findRichText: true), findsOneWidget);
expect(find.text('# Kana'), findsNothing);
// It fits on the screen, so it is read to its end at once.
expect(find.text('100%'), findsOneWidget);
expect(await furthest(), {id: 100});
// The chapter it ends in, as a PDF's.
expect(find.text('Particles'), findsOneWidget);
expect(find.byTooltip('Search PDF'), findsNothing);
expect(find.byTooltip('Zoom in'), findsNothing);
await tester.tap(find.byTooltip('Show the source text'));
await tester.pumpAndSettle();
expect(find.textContaining('**first**'), findsOneWidget);
await tester.tap(find.byTooltip('Show it formatted'));
await tester.pumpAndSettle();
expect(find.textContaining('**first**'), findsNothing);
});
testWidgets('a long Markdown file opens where reading left off, in '
'shares', (tester) async {
final id = await storedMarkdown(paragraphs: 400, readTo: 51);
await read(tester, id);
expect(find.text('50%'), findsOneWidget);
expect(
find.textContaining('Line 200.', findRichText: true),
findsOneWidget,
);
expect(find.textContaining('Line 0.', findRichText: true), findsNothing);
});
testWidgets("a PDF whose file isn't on this device says so", (tester) async {
final id = await stored();
files.files.clear();

View file

@ -609,7 +609,7 @@ void main() {
'Create a path',
'Create flashcards',
'Create a note',
'Upload a PDF',
'Upload a file',
])
tester.getRect(find.text(item)).top,
];
@ -816,7 +816,7 @@ void main() {
'Files',
LucideIcons.folder,
'No files yet',
'"Add material → Upload a PDF"',
'"Add material → Upload a file"',
),
]) {
await openPart(tester, part);