fix(app): Reorder among older items, export and text import that hold up

- Reorder: rows from before an order existed all share one position, and
  one moved among them landed at their end; the run it goes into now
  gets positions of its own (placeAmong), for notes, files, paths,
  steps and sub-studysets. Rows whose deletion can still be undone count
  for nothing, as the list leaves them out, and a dropped row takes its
  place at once instead of snapping back until the order is read again.
- Export: two studysets inside each other (moved so on two devices
  offline) no longer make it run forever; links to files whose names
  hold #, % or brackets work; PDFs, images and sounds go into the zip
  without being compressed again; any error says the export failed.
- Text import: a real CSV reader (quoted fields over several lines, a
  back over several quoted fields, a stray quote spoiling only its own
  line), the separator guessed from the text (semicolons from German
  spreadsheets), and files in UTF-16 or Windows-1252 read as meant.
- A tab whose notes, paths or files couldn't be read says so rather than
  "No … yet"; an Anki import from the Library that fails leaves no empty
  studyset behind.

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:
Tizian 「ティツィアーン」 2026-10-09 13:02:32 +02:00
commit 084af278b5
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
30 changed files with 727 additions and 138 deletions

View file

@ -262,11 +262,11 @@
"studysetImportCards": "Karteikarten", "studysetImportCards": "Karteikarten",
"studysetImportCardsHint": "Eingefügt oder aus einer CSV- oder TSV-Datei", "studysetImportCardsHint": "Eingefügt oder aus einer CSV- oder TSV-Datei",
"cardTextImportTitle": "Karteikarten importieren", "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.", "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. Ein Feld in doppelten Anführungszeichen, wie Tabellenprogramme sie schreiben, darf Trennzeichen und Zeilenumbrüche enthalten.",
"cardTextImportHint": "Füge die Karten hier ein", "cardTextImportHint": "Füge die Karten hier ein",
"cardTextImportLoadFile": "CSV- oder TSV-Datei laden", "cardTextImportLoadFile": "CSV- oder TSV-Datei laden",
"cardTextImportFileType": "CSV- oder TSV-Dateien", "cardTextImportFileType": "CSV- oder TSV-Dateien",
"cardTextImportUnreadable": "Diese Datei ist kein Text in UTF-8.", "cardTextImportUnreadable": "Diese Datei enthält keinen Text.",
"cardTextImportSeparator": "Trennzeichen", "cardTextImportSeparator": "Trennzeichen",
"cardTextSeparatorTab": "Tab", "cardTextSeparatorTab": "Tab",
"cardTextSeparatorComma": "Komma", "cardTextSeparatorComma": "Komma",
@ -438,6 +438,9 @@
"syncMismatchMore": "Mehr", "syncMismatchMore": "Mehr",
"tagsReadFailed": "Die Tags konnten nicht gelesen werden.", "tagsReadFailed": "Die Tags konnten nicht gelesen werden.",
"notesReadFailed": "Die Notizen konnten nicht gelesen werden.",
"pathsReadFailed": "Die Lernpfade konnten nicht gelesen werden.",
"filesReadFailed": "Die Dateien konnten nicht gelesen werden.",
"tagPickerTitle": "Tags", "tagPickerTitle": "Tags",
"tagPickerManage": "Tags verwalten", "tagPickerManage": "Tags verwalten",
"tagPickerSearchLabel": "Tag suchen oder erstellen", "tagPickerSearchLabel": "Tag suchen oder erstellen",

View file

@ -290,11 +290,11 @@
"studysetImportCards": "Flashcards", "studysetImportCards": "Flashcards",
"studysetImportCardsHint": "Pasted, or from a CSV or TSV file", "studysetImportCardsHint": "Pasted, or from a CSV or TSV file",
"cardTextImportTitle": "Import flashcards", "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.", "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. A field in double quotes, as spreadsheets write them, may hold separators and line breaks.",
"cardTextImportHint": "Paste the cards here", "cardTextImportHint": "Paste the cards here",
"cardTextImportLoadFile": "Load a CSV or TSV file", "cardTextImportLoadFile": "Load a CSV or TSV file",
"cardTextImportFileType": "CSV or TSV files", "cardTextImportFileType": "CSV or TSV files",
"cardTextImportUnreadable": "This file isn't text in UTF-8.", "cardTextImportUnreadable": "This file doesn't hold text.",
"cardTextImportSeparator": "Separator", "cardTextImportSeparator": "Separator",
"cardTextSeparatorTab": "Tab", "cardTextSeparatorTab": "Tab",
"cardTextSeparatorComma": "Comma", "cardTextSeparatorComma": "Comma",
@ -475,6 +475,9 @@
"syncMismatchMore": "More", "syncMismatchMore": "More",
"tagsReadFailed": "Couldn't read the tags.", "tagsReadFailed": "Couldn't read the tags.",
"notesReadFailed": "Couldn't read the notes.",
"pathsReadFailed": "Couldn't read the learning paths.",
"filesReadFailed": "Couldn't read the files.",
"tagPickerTitle": "Tags", "tagPickerTitle": "Tags",
"tagPickerManage": "Manage tags", "tagPickerManage": "Manage tags",
"tagPickerSearchLabel": "Search or create a tag", "tagPickerSearchLabel": "Search or create a tag",

View file

@ -1633,7 +1633,7 @@ abstract class AppLocalizations {
/// No description provided for @cardTextImportDescription. /// No description provided for @cardTextImportDescription.
/// ///
/// In en, this message translates to: /// 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.'** /// **'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. A field in double quotes, as spreadsheets write them, may hold separators and line breaks.'**
String get cardTextImportDescription; String get cardTextImportDescription;
/// No description provided for @cardTextImportHint. /// No description provided for @cardTextImportHint.
@ -1657,7 +1657,7 @@ abstract class AppLocalizations {
/// No description provided for @cardTextImportUnreadable. /// No description provided for @cardTextImportUnreadable.
/// ///
/// In en, this message translates to: /// In en, this message translates to:
/// **'This file isn\'t text in UTF-8.'** /// **'This file doesn\'t hold text.'**
String get cardTextImportUnreadable; String get cardTextImportUnreadable;
/// No description provided for @cardTextImportSeparator. /// No description provided for @cardTextImportSeparator.
@ -2650,6 +2650,24 @@ abstract class AppLocalizations {
/// **'Couldn\'t read the tags.'** /// **'Couldn\'t read the tags.'**
String get tagsReadFailed; String get tagsReadFailed;
/// No description provided for @notesReadFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t read the notes.'**
String get notesReadFailed;
/// No description provided for @pathsReadFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t read the learning paths.'**
String get pathsReadFailed;
/// No description provided for @filesReadFailed.
///
/// In en, this message translates to:
/// **'Couldn\'t read the files.'**
String get filesReadFailed;
/// No description provided for @tagPickerTitle. /// No description provided for @tagPickerTitle.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View file

@ -970,7 +970,7 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get cardTextImportDescription => 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.'; '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. Ein Feld in doppelten Anführungszeichen, wie Tabellenprogramme sie schreiben, darf Trennzeichen und Zeilenumbrüche enthalten.';
@override @override
String get cardTextImportHint => 'Füge die Karten hier ein'; String get cardTextImportHint => 'Füge die Karten hier ein';
@ -982,7 +982,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get cardTextImportFileType => 'CSV- oder TSV-Dateien'; String get cardTextImportFileType => 'CSV- oder TSV-Dateien';
@override @override
String get cardTextImportUnreadable => 'Diese Datei ist kein Text in UTF-8.'; String get cardTextImportUnreadable => 'Diese Datei enthält keinen Text.';
@override @override
String get cardTextImportSeparator => 'Trennzeichen'; String get cardTextImportSeparator => 'Trennzeichen';
@ -1552,6 +1552,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override @override
String get tagsReadFailed => 'Die Tags konnten nicht gelesen werden.'; String get tagsReadFailed => 'Die Tags konnten nicht gelesen werden.';
@override
String get notesReadFailed => 'Die Notizen konnten nicht gelesen werden.';
@override
String get pathsReadFailed => 'Die Lernpfade konnten nicht gelesen werden.';
@override
String get filesReadFailed => 'Die Dateien konnten nicht gelesen werden.';
@override @override
String get tagPickerTitle => 'Tags'; String get tagPickerTitle => 'Tags';

View file

@ -966,7 +966,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get cardTextImportDescription => 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.'; '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. A field in double quotes, as spreadsheets write them, may hold separators and line breaks.';
@override @override
String get cardTextImportHint => 'Paste the cards here'; String get cardTextImportHint => 'Paste the cards here';
@ -978,7 +978,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get cardTextImportFileType => 'CSV or TSV files'; String get cardTextImportFileType => 'CSV or TSV files';
@override @override
String get cardTextImportUnreadable => 'This file isn\'t text in UTF-8.'; String get cardTextImportUnreadable => 'This file doesn\'t hold text.';
@override @override
String get cardTextImportSeparator => 'Separator'; String get cardTextImportSeparator => 'Separator';
@ -1542,6 +1542,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get tagsReadFailed => 'Couldn\'t read the tags.'; String get tagsReadFailed => 'Couldn\'t read the tags.';
@override
String get notesReadFailed => 'Couldn\'t read the notes.';
@override
String get pathsReadFailed => 'Couldn\'t read the learning paths.';
@override
String get filesReadFailed => 'Couldn\'t read the files.';
@override @override
String get tagPickerTitle => 'Tags'; String get tagPickerTitle => 'Tags';

View file

@ -28,3 +28,55 @@ String positionAmong(List<String> others, int index) {
} }
return FractionalIndexing.generateKeyBetween(lower, upper); return FractionalIndexing.generateKeyBetween(lower, upper);
} }
/// Where an item moved to [index] among [others] (the other items in their
/// order, with their ids) goes: its new position under [movedId], and new
/// positions for others where it needs them. Items that share a position
/// (all those from before an order existed stand at `a0`) leave no room
/// between them, so a run of them around the place gets positions of its
/// own first, in the order shown, the moved item among them.
Map<String, String> placeAmong(
List<({String id, String position})> others,
int index,
String movedId,
) {
final at = index.clamp(0, others.length);
final lower = at > 0 ? others[at - 1].position : null;
final next = at < others.length ? others[at].position : null;
if (lower == null || next == null || lower != next) {
return {
movedId: positionAmong([for (final other in others) other.position], at),
};
}
var start = at - 1;
while (start > 0 && others[start - 1].position == lower) {
start--;
}
var end = at;
while (end < others.length && others[end].position == lower) {
end++;
}
final keys = FractionalIndexing.generateNKeysBetween(
start > 0 ? others[start - 1].position : null,
end < others.length ? others[end].position : null,
end - start + 1,
);
return {
for (var i = start; i < at; i++) others[i].id: keys[i - start],
movedId: keys[at - start],
for (var i = at; i < end; i++) others[i].id: keys[i - start + 1],
};
}
/// [items] with the one [isMoved] picks at [toIndex] among the others, as
/// a list shows a row dropped there before its new position is stored;
/// as they are when none is picked.
List<T> movedTo<T>(List<T> items, bool Function(T item) isMoved, int toIndex) {
final from = items.indexWhere(isMoved);
if (from < 0) {
return items;
}
final moved = [...items];
final item = moved.removeAt(from);
return moved..insert(toIndex.clamp(0, moved.length), item);
}

View file

@ -87,15 +87,16 @@ class AnkiImportBloc(
if (state case final AnkiImportStateReady ready when package != null) { if (state case final AnkiImportStateReady ready when package != null) {
final options = ready.options; final options = ready.options;
emit(AnkiImportStateRunning(ready.summary, options, 0)); emit(AnkiImportStateRunning(ready.summary, options, 0));
String? made;
try { try {
final result = await _importer.import( final result = await _importer.import(
package, package,
studysetId: studysetId:
_studysetId ?? _studysetId ??
await _importer.newStudysetFor( (made = await _importer.newStudysetFor(
package, package,
fallbackTitle: _fileTitle, fallbackTitle: _fileTitle,
), )),
mode: options.mode, mode: options.mode,
keepProgress: options.keepProgress && ready.summary.hasReviews, keepProgress: options.keepProgress && ready.summary.hasReviews,
includeMedia: options.includeMedia, includeMedia: options.includeMedia,
@ -106,6 +107,13 @@ class AnkiImportBloc(
emit(AnkiImportStateDone(result, options.mode)); emit(AnkiImportStateDone(result, options.mode));
} on Exception catch (error) { } on Exception catch (error) {
log('Importing an Anki package failed', name: 'Anki', error: error); log('Importing an Anki package failed', name: 'Anki', error: error);
if (made != null) {
try {
await _importer.discardIfEmpty(made);
} on Object catch (error) {
log('Discarding its studyset failed', name: 'Anki', error: error);
}
}
emit(const AnkiImportStateFailed(null)); emit(const AnkiImportStateFailed(null));
} }
} }

View file

@ -69,6 +69,15 @@ class AnkiImporter(
); );
} }
/// Takes away the studyset [studysetId] made for an import that failed,
/// if nothing came into it, so a failure leaves no empty studyset behind.
/// One that holds cards stays: what was imported is there to see.
Future<void> discardIfEmpty(String studysetId) async {
if ((await _cards.cardsOf(studysetId)).isEmpty) {
await _studysets.delete(studysetId);
}
}
/// Imports [package] into the studyset [studysetId] as [mode] says, its /// Imports [package] into the studyset [studysetId] as [mode] says, its
/// review history if [keepProgress], its files if [includeMedia]; /// review history if [keepProgress], its files if [includeMedia];
/// [onProgress] hears how far it got, from 0 to 1. /// [onProgress] hears how far it got, from 0 to 1.

View file

@ -16,62 +16,160 @@ enum CardSeparator(final String char) {
/// A card's front and back, read from a line of text. /// A card's front and back, read from a line of text.
typedef CardLine = ({String front, String back}); typedef CardLine = ({String front, String back});
/// The cards in [text]: one per line, split at the line's first [separator], /// The cards in [text]: one per record, as spreadsheets write them: a line,
/// so the back may hold the separator too. A field in double quotes, as /// unless a field in double quotes runs over several (a doubled quote
/// spreadsheets write one holding the separator, is read without them (a /// inside stands for one). A record's fields are split at [separator]
/// doubled quote inside standing for one). Blank lines, lines without the /// outside quotes; the first is the front, the rest the back, joined by the
/// separator and lines with an empty side (a header without a back, say) /// separator again, so a back may hold one too. Blank records, those
/// are left out. /// without a second field and those with an empty side (a header without a
/// back, say) are left out, and so is a line whose quote never closes.
List<CardLine> parseCardLines(String text, CardSeparator separator) => [ List<CardLine> parseCardLines(String text, CardSeparator separator) => [
for (final line in const LineSplitter().convert( for (final fields in _records(_withoutBom(text), separator.char))
// Spreadsheets put a byte order mark before UTF-8 text. if (fields.length >= 2)
text.startsWith('') ? text.substring(1) : text, if ((
)) front: fields.first.trim(),
if (_split(line, separator.char) case final card? back: fields.skip(1).join(separator.char).trim(),
when card.front.isNotEmpty && card.back.isNotEmpty) )
card, case final card when card.front.isNotEmpty && card.back.isNotEmpty)
card,
]; ];
CardLine? _split(String line, String separator) { /// The separator [text] uses, by what most of its first lines hold outside
final start = line.length - line.trimLeft().length; /// quotes: tabs, semicolons (what spreadsheets write where a comma is the
if (!line.startsWith('"', start)) { /// decimal mark, in German say) or commas; by [fileName] when they hold
final at = line.indexOf(separator); /// none ([CardSeparator.forFile]).
return at < 0 CardSeparator guessSeparator(String text, {required String fileName}) {
? null final lines = [
: ( for (final line in const LineSplitter().convert(_withoutBom(text)))
front: line.substring(0, at).trim(), if (line.trim().isNotEmpty) line.replaceAll(RegExp('"[^"]*"'), ''),
back: _unquoted(line.substring(at + separator.length).trim()), ].take(20);
); var best = CardSeparator.forFile(fileName);
} var bestCount = 0;
// A quoted front: up to its closing quote, then the separator. for (final separator in CardSeparator.values) {
final front = StringBuffer(); final count = lines.where((line) => line.contains(separator.char)).length;
var i = start + 1; if (count > bestCount) {
while (true) { best = separator;
if (i >= line.length) { bestCount = count;
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); return best;
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. /// The text in [bytes] as a spreadsheet or text editor saved it: UTF-8, or
String _unquoted(String field) => /// UTF-16 with its byte order mark (what Excel saves as "Unicode text"), or
field.length >= 2 && field.startsWith('"') && field.endsWith('"') /// else Windows-1252 (what older Excel saves as CSV in western Europe).
? field.substring(1, field.length - 1).replaceAll('""', '"') String decodeCardText(List<int> bytes) {
: field; if (bytes.length >= 2) {
final (first, second) = (bytes[0], bytes[1]);
final littleEndian = first == 0xFF && second == 0xFE;
if (littleEndian || (first == 0xFE && second == 0xFF)) {
return String.fromCharCodes([
for (var i = 2; i + 1 < bytes.length; i += 2)
if (littleEndian)
bytes[i] | bytes[i + 1] << 8
else
bytes[i] << 8 | bytes[i + 1],
]);
}
}
try {
return utf8.decode(bytes);
} on FormatException {
return String.fromCharCodes([
for (final byte in bytes) _windows1252[byte] ?? byte,
]);
}
}
/// Where Windows-1252 differs from Latin-1: 0x80 to 0x9F hold signs (€,
/// curly quotes, dashes) rather than control characters.
const _windows1252 = {
0x80: 0x20AC,
0x82: 0x201A,
0x83: 0x0192,
0x84: 0x201E,
0x85: 0x2026,
0x86: 0x2020,
0x87: 0x2021,
0x88: 0x02C6,
0x89: 0x2030,
0x8A: 0x0160,
0x8B: 0x2039,
0x8C: 0x0152,
0x8E: 0x017D,
0x91: 0x2018,
0x92: 0x2019,
0x93: 0x201C,
0x94: 0x201D,
0x95: 0x2022,
0x96: 0x2013,
0x97: 0x2014,
0x98: 0x02DC,
0x99: 0x2122,
0x9A: 0x0161,
0x9B: 0x203A,
0x9C: 0x0153,
0x9E: 0x017E,
0x9F: 0x0178,
};
/// [text] without the byte order mark spreadsheets put before UTF-8 text.
String _withoutBom(String text) =>
text.startsWith('\uFEFF') ? text.substring(1) : text;
/// The records of [text], each its fields: split at [separator] outside
/// double quotes, a record ending at a line break outside them. A quote
/// that never closes spoils only its own line: reading goes on after it.
List<List<String>> _records(String text, String separator) {
final records = <List<String>>[];
var i = 0;
while (i < text.length) {
final start = i;
final fields = <String>[];
var field = StringBuffer();
var quoted = false;
var unclosed = false;
while (true) {
if (i >= text.length) {
unclosed = quoted;
break;
}
final char = text[i];
if (quoted) {
if (char == '"' && text.startsWith('""', i)) {
field.write('"');
i += 2;
} else if (char == '"') {
quoted = false;
i++;
} else {
field.write(char);
i++;
}
} else if (char == '"' && field.toString().trim().isEmpty) {
// A quoted field, spaces before it left out.
field = StringBuffer();
quoted = true;
i++;
} else if (text.startsWith(separator, i)) {
fields.add(field.toString());
field = StringBuffer();
i += separator.length;
} else if (char == '\n' || char == '\r') {
i += char == '\r' && text.startsWith('\r\n', i) ? 2 : 1;
break;
} else {
field.write(char);
i++;
}
}
if (unclosed) {
final end = text.indexOf('\n', start);
i = end < 0 ? text.length : end + 1;
continue;
}
fields.add(field.toString());
records.add(fields);
}
return records;
}

View file

@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:halkyon_learn_flutter/l10n/app_localizations.dart'; 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/content/rich_content.dart';
@ -53,8 +52,9 @@ class _CardTextSheetState() extends State<_CardTextSheet> {
super.dispose(); super.dispose();
} }
/// Reads a CSV or TSV file into the field, its separator chosen by its /// Reads a CSV or TSV file into the field, in whichever encoding it was
/// name (the person may still change it). /// saved, its separator guessed from what it holds (the person may still
/// change it).
Future<void> _load() async { Future<void> _load() async {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final file = await DependenciesScope.of(context).files.pick( final file = await DependenciesScope.of(context).files.pick(
@ -64,16 +64,12 @@ class _CardTextSheetState() extends State<_CardTextSheet> {
if (file == null || !mounted) { if (file == null || !mounted) {
return; return;
} }
try { final text = decodeCardText(file.bytes);
final text = utf8.decode(file.bytes); setState(() {
setState(() { _text.text = text;
_text.text = text; _separator = guessSeparator(text, fileName: file.name);
_separator = CardSeparator.forFile(file.name); _unreadable = text.contains('\u0000');
_unreadable = false; });
});
} on FormatException {
setState(() => _unreadable = true);
}
} }
@override @override

View file

@ -83,7 +83,7 @@ class StudysetMarkdownExporter(
throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset'); throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset');
} }
final export = _Export(texts, style, _fileOf); final export = _Export(texts, style, _fileOf);
await _studyset(export, root, all, depth: 1); await _studyset(export, root, all, depth: 1, seen: {});
return ( return (
markdown: '${export.blocks.join('\n\n')}\n', markdown: '${export.blocks.join('\n\n')}\n',
files: export.files, files: export.files,
@ -91,12 +91,19 @@ class StudysetMarkdownExporter(
); );
} }
/// [studyset] and everything inside it, once each: two studysets inside
/// each other (moved so on two devices offline) end the walk, as [seen]
/// keeps those written.
Future<void> _studyset( Future<void> _studyset(
_Export out, _Export out,
Studyset studyset, Studyset studyset,
List<Studyset> all, { List<Studyset> all, {
required int depth, required int depth,
required Set<String> seen,
}) async { }) async {
if (!seen.add(studyset.id)) {
return;
}
final content = studyset.content; final content = studyset.content;
out.heading(depth, content.title); out.heading(depth, content.title);
if (content.secondaryTitle case final secondary? if (content.secondaryTitle case final secondary?
@ -184,7 +191,7 @@ class StudysetMarkdownExporter(
} }
for (final child in childrenOf(studyset.id, all)) { for (final child in childrenOf(studyset.id, all)) {
await _studyset(out, child, all, depth: depth + 1); await _studyset(out, child, all, depth: depth + 1, seen: seen);
} }
} }
@ -431,7 +438,7 @@ class _Export(
'.${_extensionOf(mimeType)}', '.${_extensionOf(mimeType)}',
); );
files.add((path: path, bytes: file.bytes)); files.add((path: path, bytes: file.bytes));
return _links[assetId] = path; return _links[assetId] = _linkTo(path);
} }
/// [name] in [folder], numbered (`-2`, `-3`…) where a file has it already. /// [name] in [folder], numbered (`-2`, `-3`…) where a file has it already.
@ -448,6 +455,15 @@ class _Export(
} }
} }
/// [path] as a Markdown link names it: the characters that would end the
/// link or start a fragment there (`#`, `%`, brackets, …) percent-encoded,
/// the rest (Japanese, say) as it is.
String _linkTo(String path) => path.replaceAllMapped(
RegExp(r'[%#?()\[\]<> ]'),
(match) =>
'%${match[0]!.codeUnitAt(0).toRadixString(16).toUpperCase().padLeft(2, '0')}',
);
/// [name] without what file systems refuse in a file's name, and spaces as /// [name] without what file systems refuse in a file's name, and spaces as
/// underscores; null for none left. /// underscores; null for none left.
String? _safe(String? name) { String? _safe(String? name) {

View file

@ -41,7 +41,7 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
texts: exportTexts(l10n), texts: exportTexts(l10n),
style: style, style: style,
); );
} on Exception catch (_) { } on Object catch (_) {
say(l10n.exportFailed); say(l10n.exportFailed);
return; return;
} }
@ -58,7 +58,14 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
} else { } else {
final archive = Archive()..add(ArchiveFile.bytes('$name.md', markdown)); final archive = Archive()..add(ArchiveFile.bytes('$name.md', markdown));
for (final file in exported.files) { for (final file in exported.files) {
archive.add(ArchiveFile.bytes(file.path, file.bytes)); archive.add(
ArchiveFile.bytes(file.path, file.bytes)
// PDFs, images and sounds are compressed already: deflating
// them again takes long and saves nothing.
..compression = _compressed.hasMatch(file.path)
? CompressionType.none
: null,
);
} }
saved = await saver.save( saved = await saver.save(
name: '$name.zip', name: '$name.zip',
@ -66,7 +73,7 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
mimeType: 'application/zip', mimeType: 'application/zip',
); );
} }
} on Exception catch (_) { } on Object catch (_) {
say(l10n.exportFailed); say(l10n.exportFailed);
return; return;
} }
@ -121,3 +128,9 @@ String _fileStem(String title) {
.replaceAll(RegExp(r'\s+'), ' '); .replaceAll(RegExp(r'\s+'), ' ');
return stem.isEmpty || stem.startsWith('.') ? 'studyset' : stem; return stem.isEmpty || stem.startsWith('.') ? 'studyset' : stem;
} }
/// Files whose content is compressed already, by their extension.
final _compressed = RegExp(
r'\.(pdf|png|jpe?g|gif|webp|mp3|m4a|aac|ogg|opus|mp4|webm|zip)$',
caseSensitive: false,
);

View file

@ -4,6 +4,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart'; import 'package:halkyon_learn_flutter/src/core/content/rich_content.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/model/exercise.dart';
@ -52,10 +53,24 @@ class PathEditorBloc(
); );
emit(state.copyWith(added: created.stepId)); emit(state.copyWith(added: created.stepId));
}); });
on<PathEditorEventStepMoved>( on<PathEditorEventStepMoved>((event, emit) {
(event, emit) => // The list takes the new order at once; the stored one follows.
_paths.moveStep(_pathId, event.stepId, toIndex: event.toIndex), emit(
); state.copyWith(
steps: movedTo(
state.steps,
(step) => step.id == event.stepId,
event.toIndex,
),
),
);
return _paths.moveStep(
_pathId,
event.stepId,
toIndex: event.toIndex,
hidden: _pending,
);
});
} }
List<LearningPathStep>? _steps; List<LearningPathStep>? _steps;

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/learning_path_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart'; import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
@ -57,10 +58,24 @@ class PathListBloc(
); );
} }
}); });
on<PathListEventMoveRequested>( on<PathListEventMoveRequested>((event, emit) {
(event, emit) => // The list takes the new order at once; the stored one follows.
_paths.movePath(_studysetId, event.id, toIndex: event.toIndex), emit(
); state.copyWith(
paths: movedTo(
state.paths,
(path) => path.id == event.id,
event.toIndex,
),
),
);
return _paths.movePath(
_studysetId,
event.id,
toIndex: event.toIndex,
hidden: _pending,
);
});
on<PathListEventResetRequested>((event, emit) async { on<PathListEventResetRequested>((event, emit) async {
final stepIds = { final stepIds = {
for (final step in await _paths.stepsOf(event.id)) step.id, for (final step in await _paths.stepsOf(event.id)) step.id,

View file

@ -77,20 +77,27 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
_changes.add(null); _changes.add(null);
} }
/// Moves the path [id] to [toIndex] among the paths of [studysetId]. /// Moves the path [id] to [toIndex] among the paths of [studysetId] the
/// list shows: those [hidden] (their deletion can still be undone) left
/// out, as the list leaves them out.
Future<void> movePath( Future<void> movePath(
String studysetId, String studysetId,
String id, { String id, {
required int toIndex, required int toIndex,
Set<String> hidden = const {},
}) async { }) async {
final others = [ final others = [
for (final path in await pathsOf(studysetId)) for (final path in await pathsOf(studysetId))
if (path.id != id) (id: path.id, position: path.position), if (path.id != id && !hidden.contains(path.id))
(id: path.id, position: path.position),
]; ];
await _gateway.updatePathPosition( for (final MapEntry(key: moved, value: position) in placeAmong(
others,
toIndex,
id, id,
positionAmong([for (final other in others) other.position], toIndex), ).entries) {
); await _gateway.updatePathPosition(moved, position);
}
_changes.add(null); _changes.add(null);
} }
@ -181,20 +188,27 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
return id; return id;
} }
/// Moves the step [id] to [toIndex] among the steps of [pathId]. /// Moves the step [id] to [toIndex] among the steps of [pathId] the
/// editor shows: those [hidden] (their deletion can still be undone) left
/// out, as it leaves them out.
Future<void> moveStep( Future<void> moveStep(
String pathId, String pathId,
String id, { String id, {
required int toIndex, required int toIndex,
Set<String> hidden = const {},
}) async { }) async {
final others = [ final others = [
for (final row in await _gateway.steps({pathId})) for (final row in await _gateway.steps({pathId}))
if (row.id != id) (id: row.id, position: row.position), if (row.id != id && !hidden.contains(row.id))
(id: row.id, position: row.position),
]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id)); ]..sort((a, b) => _byPosition(a.position, a.id, b.position, b.id));
await _gateway.updateStepPosition( for (final MapEntry(key: moved, value: position) in placeAmong(
others,
toIndex,
id, id,
positionAmong([for (final other in others) other.position], toIndex), ).entries) {
); await _gateway.updateStepPosition(moved, position);
}
_changes.add(null); _changes.add(null);
} }

View file

@ -44,6 +44,13 @@ class const SliverPathList({
child: const AppLoadingIndicator(), child: const AppLoadingIndicator(),
); );
} }
if (state.failed && state.paths.isEmpty) {
// Read once, they show; nothing to add until then.
return SliverCentered(
padding: padding,
child: Text(l10n.pathsReadFailed),
);
}
if (state.paths.isEmpty) { if (state.paths.isEmpty) {
return SliverCentered( return SliverCentered(
padding: padding, padding: padding,

View file

@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_deletions.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart'; import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
@ -16,10 +17,24 @@ class NoteListBloc(
) extends Bloc<NoteListEvent, NoteListState> { ) extends Bloc<NoteListEvent, NoteListState> {
this : super(const NoteListState()) { this : super(const NoteListState()) {
on<NoteListEventStarted>(_onStarted); on<NoteListEventStarted>(_onStarted);
on<NoteListEventMoveRequested>( on<NoteListEventMoveRequested>((event, emit) {
(event, emit) => // The list takes the new order at once; the stored one follows.
_notes.move(_studysetId, event.id, toIndex: event.toIndex), emit(
); state.copyWith(
notes: movedTo(
state.notes,
(note) => note.id == event.id,
event.toIndex,
),
),
);
return _notes.move(
_studysetId,
event.id,
toIndex: event.toIndex,
hidden: _pending,
);
});
} }
/// The notes as last read, and whether they were read at all. /// The notes as last read, and whether they were read at all.

View file

@ -118,17 +118,27 @@ class NoteRepository(final NoteGateway _gateway) {
return ids; return ids;
} }
/// Moves the note [noteId] to [toIndex] among the notes of [studysetId]. /// Moves the note [noteId] to [toIndex] among the notes of [studysetId]
/// the list shows: those [hidden] (their deletion can still be undone)
/// left out, as the list leaves them out.
Future<void> move( Future<void> move(
String studysetId, String studysetId,
String noteId, { String noteId, {
required int toIndex, required int toIndex,
Set<String> hidden = const {},
}) async { }) async {
final others = [ final others = [
for (final note in await notesOf(studysetId)) for (final note in await notesOf(studysetId))
if (note.id != noteId) note.position, if (note.id != noteId && !hidden.contains(note.id))
(id: note.id, position: note.position),
]; ];
await _gateway.updatePosition(noteId, positionAmong(others, toIndex)); for (final MapEntry(key: id, value: position) in placeAmong(
others,
toIndex,
noteId,
).entries) {
await _gateway.updatePosition(id, position);
}
_changes.add(null); _changes.add(null);
} }

View file

@ -44,6 +44,13 @@ class const SliverNoteList({
child: const AppLoadingIndicator(), child: const AppLoadingIndicator(),
); );
} }
if (state.failed && state.notes.isEmpty) {
// Read once, they show; nothing to add until then.
return SliverCentered(
padding: padding,
child: Text(l10n.notesReadFailed),
);
}
if (state.notes.isEmpty) { if (state.notes.isEmpty) {
return SliverCentered( return SliverCentered(
padding: padding, padding: padding,

View file

@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart'; import 'package:halkyon_learn_flutter/src/core/asset/data/asset_repository.dart';
import 'package:halkyon_learn_flutter/src/core/sync/fractional_position.dart';
import 'package:halkyon_learn_flutter/src/feature/card/data/card_repository.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.dart';
import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart'; import 'package:halkyon_learn_flutter/src/feature/note/data/note_repository.dart';
@ -69,10 +70,24 @@ class PdfListBloc(
await _import(emit, bytes, fileName, true); await _import(emit, bytes, fileName, true);
} }
}); });
on<PdfListEventMoveRequested>( on<PdfListEventMoveRequested>((event, emit) {
(event, emit) => // The list takes the new order at once; the stored one follows.
_pdfs.move(_studysetId, event.id, toIndex: event.toIndex), emit(
); state.copyWith(
rows: movedTo(
state.rows,
(row) => row.source.id == event.id,
event.toIndex,
),
),
);
return _pdfs.move(
_studysetId,
event.id,
toIndex: event.toIndex,
hidden: _pending,
);
});
on<PdfListEventRenamed>( on<PdfListEventRenamed>(
(event, emit) => _pdfs.rename(event.pdfId, event.title), (event, emit) => _pdfs.rename(event.pdfId, event.title),
); );

Some files were not shown because too many files have changed in this diff Show more