feat(app): import flashcards from text
Import content gains Flashcards: pasted, or from a CSV or TSV file, one card per line, split at its first tab, comma or semicolon (a .csv picks commas), lines without both sides left out. Quoted fields are read as spreadsheets write them, and a UTF-8 byte order mark is ignored. The cards come in at the studyset's end with the origin "text", so the dashboard doesn't count them as written. 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:
parent
a35318a5a6
commit
18c1370bf6
10 changed files with 728 additions and 1 deletions
|
|
@ -242,6 +242,22 @@
|
|||
"studysetImportPathHint": "Aus einer Halkyon-Pfad-JSON-Datei",
|
||||
"studysetImportAnki": "Anki-Deck",
|
||||
"studysetImportAnkiHint": "Aus einer .apkg- oder .colpkg-Datei",
|
||||
"studysetImportCards": "Karteikarten",
|
||||
"studysetImportCardsHint": "Eingefügt oder aus einer CSV- oder TSV-Datei",
|
||||
"cardTextImportTitle": "Karteikarten importieren",
|
||||
"cardTextImportDescription": "Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen.",
|
||||
"cardTextImportHint": "Füge die Karten hier ein",
|
||||
"cardTextImportLoadFile": "CSV- oder TSV-Datei laden",
|
||||
"cardTextImportFileType": "CSV- oder TSV-Dateien",
|
||||
"cardTextImportUnreadable": "Diese Datei ist kein Text in UTF-8.",
|
||||
"cardTextImportSeparator": "Trennzeichen",
|
||||
"cardTextSeparatorTab": "Tab",
|
||||
"cardTextSeparatorComma": "Komma",
|
||||
"cardTextSeparatorSemicolon": "Semikolon",
|
||||
"cardTextImportNone": "Noch keine Karten erkannt.",
|
||||
"cardTextImportReady": "{count, plural, =1{1 Karte bereit zum Importieren.} other{{count} Karten bereit zum Importieren.}}",
|
||||
"cardTextImportSubmit": "Importieren",
|
||||
"cardTextImported": "{count, plural, =1{1 Karteikarte importiert.} other{{count} Karteikarten importiert.}}",
|
||||
"studysetReorder": "Umsortieren",
|
||||
"studysetReorderPaths": "Pfade umsortieren",
|
||||
"studysetReorderDone": "Fertig",
|
||||
|
|
|
|||
|
|
@ -268,6 +268,24 @@
|
|||
"studysetImportPathHint": "From a Halkyon path JSON file",
|
||||
"studysetImportAnki": "Anki deck",
|
||||
"studysetImportAnkiHint": "From an .apkg or .colpkg file",
|
||||
"studysetImportCards": "Flashcards",
|
||||
"studysetImportCardsHint": "Pasted, or from a CSV or TSV file",
|
||||
"cardTextImportTitle": "Import flashcards",
|
||||
"cardTextImportDescription": "One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.",
|
||||
"cardTextImportHint": "Paste the cards here",
|
||||
"cardTextImportLoadFile": "Load a CSV or TSV file",
|
||||
"cardTextImportFileType": "CSV or TSV files",
|
||||
"cardTextImportUnreadable": "This file isn't text in UTF-8.",
|
||||
"cardTextImportSeparator": "Separator",
|
||||
"cardTextSeparatorTab": "Tab",
|
||||
"cardTextSeparatorComma": "Comma",
|
||||
"cardTextSeparatorSemicolon": "Semicolon",
|
||||
"cardTextImportNone": "No cards found yet.",
|
||||
"cardTextImportReady": "{count, plural, =1{1 card ready to import.} other{{count} cards ready to import.}}",
|
||||
"@cardTextImportReady": {"placeholders": {"count": {"type": "int"}}},
|
||||
"cardTextImportSubmit": "Import",
|
||||
"cardTextImported": "{count, plural, =1{Imported 1 flashcard.} other{Imported {count} flashcards.}}",
|
||||
"@cardTextImported": {"placeholders": {"count": {"type": "int"}}},
|
||||
"studysetReorder": "Reorder",
|
||||
"studysetReorderPaths": "Reorder paths",
|
||||
"studysetReorderDone": "Done",
|
||||
|
|
|
|||
|
|
@ -1510,6 +1510,102 @@ abstract class AppLocalizations {
|
|||
/// **'From an .apkg or .colpkg file'**
|
||||
String get studysetImportAnkiHint;
|
||||
|
||||
/// No description provided for @studysetImportCards.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Flashcards'**
|
||||
String get studysetImportCards;
|
||||
|
||||
/// No description provided for @studysetImportCardsHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Pasted, or from a CSV or TSV file'**
|
||||
String get studysetImportCardsHint;
|
||||
|
||||
/// No description provided for @cardTextImportTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import flashcards'**
|
||||
String get cardTextImportTitle;
|
||||
|
||||
/// No description provided for @cardTextImportDescription.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.'**
|
||||
String get cardTextImportDescription;
|
||||
|
||||
/// No description provided for @cardTextImportHint.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Paste the cards here'**
|
||||
String get cardTextImportHint;
|
||||
|
||||
/// No description provided for @cardTextImportLoadFile.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Load a CSV or TSV file'**
|
||||
String get cardTextImportLoadFile;
|
||||
|
||||
/// No description provided for @cardTextImportFileType.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'CSV or TSV files'**
|
||||
String get cardTextImportFileType;
|
||||
|
||||
/// No description provided for @cardTextImportUnreadable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This file isn\'t text in UTF-8.'**
|
||||
String get cardTextImportUnreadable;
|
||||
|
||||
/// No description provided for @cardTextImportSeparator.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Separator'**
|
||||
String get cardTextImportSeparator;
|
||||
|
||||
/// No description provided for @cardTextSeparatorTab.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Tab'**
|
||||
String get cardTextSeparatorTab;
|
||||
|
||||
/// No description provided for @cardTextSeparatorComma.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Comma'**
|
||||
String get cardTextSeparatorComma;
|
||||
|
||||
/// No description provided for @cardTextSeparatorSemicolon.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Semicolon'**
|
||||
String get cardTextSeparatorSemicolon;
|
||||
|
||||
/// No description provided for @cardTextImportNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No cards found yet.'**
|
||||
String get cardTextImportNone;
|
||||
|
||||
/// No description provided for @cardTextImportReady.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{1 card ready to import.} other{{count} cards ready to import.}}'**
|
||||
String cardTextImportReady(int count);
|
||||
|
||||
/// No description provided for @cardTextImportSubmit.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Import'**
|
||||
String get cardTextImportSubmit;
|
||||
|
||||
/// No description provided for @cardTextImported.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'{count, plural, =1{Imported 1 flashcard.} other{Imported {count} flashcards.}}'**
|
||||
String cardTextImported(int count);
|
||||
|
||||
/// No description provided for @studysetReorder.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -890,6 +890,72 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get studysetImportAnkiHint => 'Aus einer .apkg- oder .colpkg-Datei';
|
||||
|
||||
@override
|
||||
String get studysetImportCards => 'Karteikarten';
|
||||
|
||||
@override
|
||||
String get studysetImportCardsHint =>
|
||||
'Eingefügt oder aus einer CSV- oder TSV-Datei';
|
||||
|
||||
@override
|
||||
String get cardTextImportTitle => 'Karteikarten importieren';
|
||||
|
||||
@override
|
||||
String get cardTextImportDescription =>
|
||||
'Eine Karte pro Zeile: ihre Vorderseite, ein Trennzeichen, dann ihre Rückseite. Jede Zeile wird an ihrem ersten Trennzeichen geteilt; Zeilen ohne eines werden ausgelassen.';
|
||||
|
||||
@override
|
||||
String get cardTextImportHint => 'Füge die Karten hier ein';
|
||||
|
||||
@override
|
||||
String get cardTextImportLoadFile => 'CSV- oder TSV-Datei laden';
|
||||
|
||||
@override
|
||||
String get cardTextImportFileType => 'CSV- oder TSV-Dateien';
|
||||
|
||||
@override
|
||||
String get cardTextImportUnreadable => 'Diese Datei ist kein Text in UTF-8.';
|
||||
|
||||
@override
|
||||
String get cardTextImportSeparator => 'Trennzeichen';
|
||||
|
||||
@override
|
||||
String get cardTextSeparatorTab => 'Tab';
|
||||
|
||||
@override
|
||||
String get cardTextSeparatorComma => 'Komma';
|
||||
|
||||
@override
|
||||
String get cardTextSeparatorSemicolon => 'Semikolon';
|
||||
|
||||
@override
|
||||
String get cardTextImportNone => 'Noch keine Karten erkannt.';
|
||||
|
||||
@override
|
||||
String cardTextImportReady(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Karten bereit zum Importieren.',
|
||||
one: '1 Karte bereit zum Importieren.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardTextImportSubmit => 'Importieren';
|
||||
|
||||
@override
|
||||
String cardTextImported(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count Karteikarten importiert.',
|
||||
one: '1 Karteikarte importiert.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetReorder => 'Umsortieren';
|
||||
|
||||
|
|
|
|||
|
|
@ -884,6 +884,71 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get studysetImportAnkiHint => 'From an .apkg or .colpkg file';
|
||||
|
||||
@override
|
||||
String get studysetImportCards => 'Flashcards';
|
||||
|
||||
@override
|
||||
String get studysetImportCardsHint => 'Pasted, or from a CSV or TSV file';
|
||||
|
||||
@override
|
||||
String get cardTextImportTitle => 'Import flashcards';
|
||||
|
||||
@override
|
||||
String get cardTextImportDescription =>
|
||||
'One card per line: its front, a separator, then its back. Each line is split at its first separator; lines without one are left out.';
|
||||
|
||||
@override
|
||||
String get cardTextImportHint => 'Paste the cards here';
|
||||
|
||||
@override
|
||||
String get cardTextImportLoadFile => 'Load a CSV or TSV file';
|
||||
|
||||
@override
|
||||
String get cardTextImportFileType => 'CSV or TSV files';
|
||||
|
||||
@override
|
||||
String get cardTextImportUnreadable => 'This file isn\'t text in UTF-8.';
|
||||
|
||||
@override
|
||||
String get cardTextImportSeparator => 'Separator';
|
||||
|
||||
@override
|
||||
String get cardTextSeparatorTab => 'Tab';
|
||||
|
||||
@override
|
||||
String get cardTextSeparatorComma => 'Comma';
|
||||
|
||||
@override
|
||||
String get cardTextSeparatorSemicolon => 'Semicolon';
|
||||
|
||||
@override
|
||||
String get cardTextImportNone => 'No cards found yet.';
|
||||
|
||||
@override
|
||||
String cardTextImportReady(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: '$count cards ready to import.',
|
||||
one: '1 card ready to import.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get cardTextImportSubmit => 'Import';
|
||||
|
||||
@override
|
||||
String cardTextImported(int count) {
|
||||
String _temp0 = intl.Intl.pluralLogic(
|
||||
count,
|
||||
locale: localeName,
|
||||
other: 'Imported $count flashcards.',
|
||||
one: 'Imported 1 flashcard.',
|
||||
);
|
||||
return '$_temp0';
|
||||
}
|
||||
|
||||
@override
|
||||
String get studysetReorder => 'Reorder';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
import 'dart:convert';
|
||||
|
||||
/// What stands between a card's front and its back on a line of text.
|
||||
enum CardSeparator(final String char) {
|
||||
/// What Quizlet and Anki export, and what text rarely holds otherwise.
|
||||
tab('\t'),
|
||||
comma(','),
|
||||
semicolon(';');
|
||||
|
||||
/// The separator a file named [fileName] most likely uses: commas in a
|
||||
/// CSV file, tabs in anything else.
|
||||
static CardSeparator forFile(String fileName) =>
|
||||
fileName.toLowerCase().endsWith('.csv') ? comma : tab;
|
||||
}
|
||||
|
||||
/// A card's front and back, read from a line of text.
|
||||
typedef CardLine = ({String front, String back});
|
||||
|
||||
/// The cards in [text]: one per line, split at the line's first [separator],
|
||||
/// so the back may hold the separator too. A field in double quotes, as
|
||||
/// spreadsheets write one holding the separator, is read without them (a
|
||||
/// doubled quote inside standing for one). Blank lines, lines without the
|
||||
/// separator and lines with an empty side (a header without a back, say)
|
||||
/// are left out.
|
||||
List<CardLine> parseCardLines(String text, CardSeparator separator) => [
|
||||
for (final line in const LineSplitter().convert(
|
||||
// Spreadsheets put a byte order mark before UTF-8 text.
|
||||
text.startsWith('') ? text.substring(1) : text,
|
||||
))
|
||||
if (_split(line, separator.char) case final card?
|
||||
when card.front.isNotEmpty && card.back.isNotEmpty)
|
||||
card,
|
||||
];
|
||||
|
||||
CardLine? _split(String line, String separator) {
|
||||
final start = line.length - line.trimLeft().length;
|
||||
if (!line.startsWith('"', start)) {
|
||||
final at = line.indexOf(separator);
|
||||
return at < 0
|
||||
? null
|
||||
: (
|
||||
front: line.substring(0, at).trim(),
|
||||
back: _unquoted(line.substring(at + separator.length).trim()),
|
||||
);
|
||||
}
|
||||
// A quoted front: up to its closing quote, then the separator.
|
||||
final front = StringBuffer();
|
||||
var i = start + 1;
|
||||
while (true) {
|
||||
if (i >= line.length) {
|
||||
return null;
|
||||
}
|
||||
if (line[i] != '"') {
|
||||
front.write(line[i++]);
|
||||
} else if (line.startsWith('""', i)) {
|
||||
front.write('"');
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
final at = line.indexOf(separator, i);
|
||||
if (at < 0 || line.substring(i, at).trim().isNotEmpty) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
front: front.toString().trim(),
|
||||
back: _unquoted(line.substring(at + separator.length).trim()),
|
||||
);
|
||||
}
|
||||
|
||||
/// [field] without the double quotes around it, if it has them.
|
||||
String _unquoted(String field) =>
|
||||
field.length >= 2 && field.startsWith('"') && field.endsWith('"')
|
||||
? field.substring(1, field.length - 1).replaceAll('""', '"')
|
||||
: field;
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/dependencies_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/dependencies/person_scope.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/widget/adaptive_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_content.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_text_import.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Asks for flashcards as text, pasted or read from a CSV or TSV file, one
|
||||
/// per line, and adds them to the end of the studyset [studysetId] in their
|
||||
/// order, as front/back cards imported from text. Completes with how many
|
||||
/// came in, or null when the sheet closed without importing.
|
||||
Future<int?> importCardsFromText(
|
||||
BuildContext context, {
|
||||
required String studysetId,
|
||||
}) async {
|
||||
final cards = PersonScope.of(context).cards;
|
||||
final lines = await showAdaptiveSheet<List<CardLine>>(
|
||||
context,
|
||||
builder: (context) => const _CardTextSheet(),
|
||||
);
|
||||
if (lines == null || lines.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
await cards.createAll(studysetId, [
|
||||
for (final line in lines)
|
||||
CardContent(
|
||||
front: RichContent.plain(line.front),
|
||||
back: RichContent.plain(line.back),
|
||||
),
|
||||
], origin: 'text');
|
||||
return lines.length;
|
||||
}
|
||||
|
||||
class const _CardTextSheet() extends StatefulWidget {
|
||||
@override
|
||||
State<_CardTextSheet> createState() => _CardTextSheetState();
|
||||
}
|
||||
|
||||
class _CardTextSheetState() extends State<_CardTextSheet> {
|
||||
final _text = TextEditingController();
|
||||
var _separator = CardSeparator.tab;
|
||||
var _unreadable = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_text.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Reads a CSV or TSV file into the field, its separator chosen by its
|
||||
/// name (the person may still change it).
|
||||
Future<void> _load() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final file = await DependenciesScope.of(context).files.pick(
|
||||
label: l10n.cardTextImportFileType,
|
||||
extensions: const ['csv', 'tsv', 'txt'],
|
||||
);
|
||||
if (file == null || !mounted) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final text = utf8.decode(file.bytes);
|
||||
setState(() {
|
||||
_text.text = text;
|
||||
_separator = CardSeparator.forFile(file.name);
|
||||
_unreadable = false;
|
||||
});
|
||||
} on FormatException {
|
||||
setState(() => _unreadable = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final lines = parseCardLines(_text.text, _separator);
|
||||
return SheetScaffold(
|
||||
title: l10n.cardTextImportTitle,
|
||||
footer: FilledButton(
|
||||
onPressed: lines.isEmpty
|
||||
? null
|
||||
: () => Navigator.of(context).pop(lines),
|
||||
child: Text(l10n.cardTextImportSubmit),
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
l10n.cardTextImportDescription,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(LucideIcons.fileUp),
|
||||
label: Text(l10n.cardTextImportLoadFile),
|
||||
onPressed: () => unawaited(_load()),
|
||||
),
|
||||
),
|
||||
if (_unreadable) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.cardTextImportUnreadable,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _text,
|
||||
minLines: 6,
|
||||
maxLines: 12,
|
||||
keyboardType: TextInputType.multiline,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.cardTextImportHint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(l10n.cardTextImportSeparator, style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
SegmentedButton<CardSeparator>(
|
||||
segments: [
|
||||
for (final each in CardSeparator.values)
|
||||
ButtonSegment(
|
||||
value: each,
|
||||
label: Text(switch (each) {
|
||||
CardSeparator.tab => l10n.cardTextSeparatorTab,
|
||||
CardSeparator.comma => l10n.cardTextSeparatorComma,
|
||||
CardSeparator.semicolon => l10n.cardTextSeparatorSemicolon,
|
||||
}),
|
||||
),
|
||||
],
|
||||
selected: {_separator},
|
||||
showSelectedIcon: false,
|
||||
onSelectionChanged: (chosen) =>
|
||||
setState(() => _separator = chosen.single),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
lines.isEmpty
|
||||
? l10n.cardTextImportNone
|
||||
: l10n.cardTextImportReady(lines.length),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ import 'package:halkyon_learn_flutter/src/core/widget/undo_snack_bar.dart';
|
|||
import 'package:halkyon_learn_flutter/src/feature/anki/data/anki_importer.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/anki/widget/anki_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_list.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/widget/card_text_import_sheet.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/bloc/path_list_bloc.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/learning_path.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/widget/path_import_texts.dart';
|
||||
|
|
@ -1161,7 +1162,8 @@ class const _AddMaterial({
|
|||
}
|
||||
|
||||
/// Asks what to import into the studyset [studysetId], and imports it: a
|
||||
/// learning path from a file in the paths' import format, or an Anki deck.
|
||||
/// learning path from a file in the paths' import format, an Anki deck, or
|
||||
/// flashcards as text.
|
||||
Future<void> _importContent(BuildContext context, String studysetId) async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final chosen = await showAdaptiveSheet<_Import>(
|
||||
|
|
@ -1186,6 +1188,13 @@ Future<void> _importContent(BuildContext context, String studysetId) async {
|
|||
subtitle: Text(l10n.studysetImportAnkiHint),
|
||||
onTap: () => Navigator.of(sheetContext).pop(_Import.anki),
|
||||
),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(LucideIcons.fileSpreadsheet),
|
||||
title: Text(l10n.studysetImportCards),
|
||||
subtitle: Text(l10n.studysetImportCardsHint),
|
||||
onTap: () => Navigator.of(sheetContext).pop(_Import.cards),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -1206,6 +1215,16 @@ Future<void> _importContent(BuildContext context, String studysetId) async {
|
|||
case AnkiImportMode.subStudysets || null:
|
||||
break;
|
||||
}
|
||||
case _Import.cards:
|
||||
final tabs = context.read<ContentTabCubit>();
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final count = await importCardsFromText(context, studysetId: studysetId);
|
||||
if (count != null) {
|
||||
tabs.show(StudysetTab.flashcards);
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.cardTextImported(count))),
|
||||
);
|
||||
}
|
||||
case null:
|
||||
break;
|
||||
}
|
||||
|
|
@ -1215,6 +1234,7 @@ Future<void> _importContent(BuildContext context, String studysetId) async {
|
|||
enum _Import() {
|
||||
path,
|
||||
anki,
|
||||
cards,
|
||||
}
|
||||
|
||||
/// Picks a file in the paths' import format and hands it to the paths'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/card/model/card_text_import.dart';
|
||||
|
||||
void main() {
|
||||
test('one card per line, split at its first separator, so a back may '
|
||||
'hold one too', () {
|
||||
expect(parseCardLines('hola\thello\nadiós\tgood\tbye', CardSeparator.tab), [
|
||||
(front: 'hola', back: 'hello'),
|
||||
(front: 'adiós', back: 'good\tbye'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('leaves out blank lines, lines without a separator and lines with '
|
||||
'an empty side, such as a header without a back', () {
|
||||
expect(
|
||||
parseCardLines(
|
||||
'Spanish vocab\n\nhola;hello\n;nothing in front\nnothing behind;\n',
|
||||
CardSeparator.semicolon,
|
||||
),
|
||||
[(front: 'hola', back: 'hello')],
|
||||
);
|
||||
});
|
||||
|
||||
test('trims both sides, and reads Windows line endings', () {
|
||||
expect(
|
||||
parseCardLines(' hola , hello \r\nsí,yes\r\n', CardSeparator.comma),
|
||||
[
|
||||
(front: 'hola', back: 'hello'),
|
||||
(front: 'sí', back: 'yes'),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('reads quoted fields as spreadsheets write them: separators inside, '
|
||||
'a doubled quote for one', () {
|
||||
expect(
|
||||
parseCardLines(
|
||||
'"hello, ""friend""",hola amigo\nthank you,"gracias, señor"',
|
||||
CardSeparator.comma,
|
||||
),
|
||||
[
|
||||
(front: 'hello, "friend"', back: 'hola amigo'),
|
||||
(front: 'thank you', back: 'gracias, señor'),
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves out a line whose quoted front never closes', () {
|
||||
expect(parseCardLines('"hello,hola', CardSeparator.comma), isEmpty);
|
||||
});
|
||||
|
||||
test('ignores the byte order mark spreadsheets put before UTF-8 text', () {
|
||||
expect(parseCardLines('hola,hello', CardSeparator.comma), [
|
||||
(front: 'hola', back: 'hello'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('a file says its separator by its name: commas for .csv, tabs for '
|
||||
'the rest', () {
|
||||
expect(CardSeparator.forFile('words.CSV'), CardSeparator.comma);
|
||||
expect(CardSeparator.forFile('words.tsv'), CardSeparator.tab);
|
||||
expect(CardSeparator.forFile('words.txt'), CardSeparator.tab);
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_learn_flutter/src/core/router/route_paths.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/studyset/data/studyset_payload.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/studyset/model/studyset_content.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
import '../../../support/fake_card_gateway.dart';
|
||||
import '../../../support/fake_file_picker.dart';
|
||||
import '../../../support/fake_studyset_gateway.dart';
|
||||
import '../../../support/pump_app.dart';
|
||||
import '../../../support/test_dependencies.dart';
|
||||
|
||||
void main() {
|
||||
late FakeStudysetGateway studysets;
|
||||
late FakeCardGateway cardGateway;
|
||||
late FakeFilePicker files;
|
||||
|
||||
setUp(() {
|
||||
studysets = FakeStudysetGateway()
|
||||
..receiveRemoteChange(
|
||||
'spanish',
|
||||
encodeStudysetPayload(StudysetContent.titled('Spanish vocab')),
|
||||
);
|
||||
cardGateway = FakeCardGateway();
|
||||
files = FakeFilePicker();
|
||||
});
|
||||
|
||||
Future<void> showApp(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(1200, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await pumpApp(
|
||||
tester,
|
||||
testDependencies(
|
||||
studysetGateway: studysets,
|
||||
cardGateway: cardGateway,
|
||||
files: files,
|
||||
),
|
||||
location: RoutePaths.studyset('spanish'),
|
||||
);
|
||||
await tester.pump(const Duration(seconds: 1));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// Opens the studyset's Import content and chooses Flashcards.
|
||||
Future<void> openImport(WidgetTester tester) async {
|
||||
await tester.tap(
|
||||
find.descendant(
|
||||
of: find.byType(AppBar),
|
||||
matching: find.byTooltip('Options'),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Import content'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Flashcards').last);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<List<(String, String)>> stored() async {
|
||||
final cards = CardRepository(cardGateway);
|
||||
addTearDown(cards.dispose);
|
||||
return [
|
||||
for (final card in await cards.cardsOf('spanish'))
|
||||
(
|
||||
card.content.front.plainText.trim(),
|
||||
card.content.back.plainText.trim(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
testWidgets('pasted lines become front/back cards at the end of the '
|
||||
'studyset, in order, marked as imported; the Flashcards tab shows '
|
||||
'them', (tester) async {
|
||||
await showApp(tester);
|
||||
await openImport(tester);
|
||||
expect(find.text('No cards found yet.'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<FilledButton>(find.widgetWithText(FilledButton, 'Import'))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField),
|
||||
'hola\thello\nadiós\tgoodbye\nno separator here',
|
||||
);
|
||||
await tester.pump();
|
||||
expect(find.text('2 cards ready to import.'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Imported 2 flashcards.'), findsOneWidget);
|
||||
expect(await stored(), [('hola', 'hello'), ('adiós', 'goodbye')]);
|
||||
expect(
|
||||
[
|
||||
for (final revision in await cardGateway.allRevisions())
|
||||
cardOriginOf(revision.payload),
|
||||
],
|
||||
['text', 'text'],
|
||||
);
|
||||
expect(find.text('hola'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a CSV file fills the field and switches to commas', (
|
||||
tester,
|
||||
) async {
|
||||
await showApp(tester);
|
||||
await openImport(tester);
|
||||
files.next = (
|
||||
name: 'words.csv',
|
||||
bytes: Uint8List.fromList(utf8.encode('"hello, friend",hola amigo\n')),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('Load a CSV or TSV file'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(files.asked.single, ['csv', 'tsv', 'txt']);
|
||||
expect(find.text('1 card ready to import.'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Import'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(await stored(), [('hello, friend', 'hola amigo')]);
|
||||
});
|
||||
|
||||
testWidgets("a file that isn't UTF-8 text says so", (tester) async {
|
||||
await showApp(tester);
|
||||
await openImport(tester);
|
||||
files.next = (name: 'words.csv', bytes: Uint8List.fromList([0xff, 0xfe]));
|
||||
|
||||
await tester.tap(find.text('Load a CSV or TSV file'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text("This file isn't text in UTF-8."), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue