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:
parent
690f69fa55
commit
151f848360
7 changed files with 1460 additions and 195 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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', () {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue