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:
parent
d5e5945d93
commit
084af278b5
30 changed files with 727 additions and 138 deletions
|
|
@ -262,11 +262,11 @@
|
|||
"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.",
|
||||
"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",
|
||||
"cardTextImportLoadFile": "CSV- oder TSV-Datei laden",
|
||||
"cardTextImportFileType": "CSV- oder TSV-Dateien",
|
||||
"cardTextImportUnreadable": "Diese Datei ist kein Text in UTF-8.",
|
||||
"cardTextImportUnreadable": "Diese Datei enthält keinen Text.",
|
||||
"cardTextImportSeparator": "Trennzeichen",
|
||||
"cardTextSeparatorTab": "Tab",
|
||||
"cardTextSeparatorComma": "Komma",
|
||||
|
|
@ -438,6 +438,9 @@
|
|||
"syncMismatchMore": "Mehr",
|
||||
|
||||
"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",
|
||||
"tagPickerManage": "Tags verwalten",
|
||||
"tagPickerSearchLabel": "Tag suchen oder erstellen",
|
||||
|
|
|
|||
|
|
@ -290,11 +290,11 @@
|
|||
"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.",
|
||||
"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",
|
||||
"cardTextImportLoadFile": "Load a CSV or TSV file",
|
||||
"cardTextImportFileType": "CSV or TSV files",
|
||||
"cardTextImportUnreadable": "This file isn't text in UTF-8.",
|
||||
"cardTextImportUnreadable": "This file doesn't hold text.",
|
||||
"cardTextImportSeparator": "Separator",
|
||||
"cardTextSeparatorTab": "Tab",
|
||||
"cardTextSeparatorComma": "Comma",
|
||||
|
|
@ -475,6 +475,9 @@
|
|||
"syncMismatchMore": "More",
|
||||
|
||||
"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",
|
||||
"tagPickerManage": "Manage tags",
|
||||
"tagPickerSearchLabel": "Search or create a tag",
|
||||
|
|
|
|||
|
|
@ -1633,7 +1633,7 @@ abstract class AppLocalizations {
|
|||
/// 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.'**
|
||||
/// **'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;
|
||||
|
||||
/// No description provided for @cardTextImportHint.
|
||||
|
|
@ -1657,7 +1657,7 @@ abstract class AppLocalizations {
|
|||
/// No description provided for @cardTextImportUnreadable.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'This file isn\'t text in UTF-8.'**
|
||||
/// **'This file doesn\'t hold text.'**
|
||||
String get cardTextImportUnreadable;
|
||||
|
||||
/// No description provided for @cardTextImportSeparator.
|
||||
|
|
@ -2650,6 +2650,24 @@ abstract class AppLocalizations {
|
|||
/// **'Couldn\'t read the tags.'**
|
||||
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.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -970,7 +970,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
|
||||
@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.';
|
||||
'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
|
||||
String get cardTextImportHint => 'Füge die Karten hier ein';
|
||||
|
|
@ -982,7 +982,7 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get cardTextImportFileType => 'CSV- oder TSV-Dateien';
|
||||
|
||||
@override
|
||||
String get cardTextImportUnreadable => 'Diese Datei ist kein Text in UTF-8.';
|
||||
String get cardTextImportUnreadable => 'Diese Datei enthält keinen Text.';
|
||||
|
||||
@override
|
||||
String get cardTextImportSeparator => 'Trennzeichen';
|
||||
|
|
@ -1552,6 +1552,15 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get tagPickerTitle => 'Tags';
|
||||
|
||||
|
|
|
|||
|
|
@ -966,7 +966,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
|
||||
@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.';
|
||||
'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
|
||||
String get cardTextImportHint => 'Paste the cards here';
|
||||
|
|
@ -978,7 +978,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get cardTextImportFileType => 'CSV or TSV files';
|
||||
|
||||
@override
|
||||
String get cardTextImportUnreadable => 'This file isn\'t text in UTF-8.';
|
||||
String get cardTextImportUnreadable => 'This file doesn\'t hold text.';
|
||||
|
||||
@override
|
||||
String get cardTextImportSeparator => 'Separator';
|
||||
|
|
@ -1542,6 +1542,15 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
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
|
||||
String get tagPickerTitle => 'Tags';
|
||||
|
||||
|
|
|
|||
|
|
@ -28,3 +28,55 @@ String positionAmong(List<String> others, int index) {
|
|||
}
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,15 +87,16 @@ class AnkiImportBloc(
|
|||
if (state case final AnkiImportStateReady ready when package != null) {
|
||||
final options = ready.options;
|
||||
emit(AnkiImportStateRunning(ready.summary, options, 0));
|
||||
String? made;
|
||||
try {
|
||||
final result = await _importer.import(
|
||||
package,
|
||||
studysetId:
|
||||
_studysetId ??
|
||||
await _importer.newStudysetFor(
|
||||
(made = await _importer.newStudysetFor(
|
||||
package,
|
||||
fallbackTitle: _fileTitle,
|
||||
),
|
||||
)),
|
||||
mode: options.mode,
|
||||
keepProgress: options.keepProgress && ready.summary.hasReviews,
|
||||
includeMedia: options.includeMedia,
|
||||
|
|
@ -106,6 +107,13 @@ class AnkiImportBloc(
|
|||
emit(AnkiImportStateDone(result, options.mode));
|
||||
} on Exception catch (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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// review history if [keepProgress], its files if [includeMedia];
|
||||
/// [onProgress] hears how far it got, from 0 to 1.
|
||||
|
|
|
|||
|
|
@ -16,62 +16,160 @@ enum CardSeparator(final String char) {
|
|||
/// 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.
|
||||
/// The cards in [text]: one per record, as spreadsheets write them: a line,
|
||||
/// unless a field in double quotes runs over several (a doubled quote
|
||||
/// inside stands for one). A record's fields are split at [separator]
|
||||
/// outside quotes; the first is the front, the rest the back, joined by the
|
||||
/// separator again, so a back may hold one too. Blank records, those
|
||||
/// 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) => [
|
||||
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,
|
||||
for (final fields in _records(_withoutBom(text), separator.char))
|
||||
if (fields.length >= 2)
|
||||
if ((
|
||||
front: fields.first.trim(),
|
||||
back: fields.skip(1).join(separator.char).trim(),
|
||||
)
|
||||
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;
|
||||
/// The separator [text] uses, by what most of its first lines hold outside
|
||||
/// quotes: tabs, semicolons (what spreadsheets write where a comma is the
|
||||
/// decimal mark, in German say) or commas; by [fileName] when they hold
|
||||
/// none ([CardSeparator.forFile]).
|
||||
CardSeparator guessSeparator(String text, {required String fileName}) {
|
||||
final lines = [
|
||||
for (final line in const LineSplitter().convert(_withoutBom(text)))
|
||||
if (line.trim().isNotEmpty) line.replaceAll(RegExp('"[^"]*"'), ''),
|
||||
].take(20);
|
||||
var best = CardSeparator.forFile(fileName);
|
||||
var bestCount = 0;
|
||||
for (final separator in CardSeparator.values) {
|
||||
final count = lines.where((line) => line.contains(separator.char)).length;
|
||||
if (count > bestCount) {
|
||||
best = separator;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
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()),
|
||||
);
|
||||
return best;
|
||||
}
|
||||
|
||||
/// [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;
|
||||
/// The text in [bytes] as a spreadsheet or text editor saved it: UTF-8, or
|
||||
/// UTF-16 with its byte order mark (what Excel saves as "Unicode text"), or
|
||||
/// else Windows-1252 (what older Excel saves as CSV in western Europe).
|
||||
String decodeCardText(List<int> bytes) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
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';
|
||||
|
|
@ -53,8 +52,9 @@ class _CardTextSheetState() extends State<_CardTextSheet> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
/// Reads a CSV or TSV file into the field, its separator chosen by its
|
||||
/// name (the person may still change it).
|
||||
/// Reads a CSV or TSV file into the field, in whichever encoding it was
|
||||
/// saved, its separator guessed from what it holds (the person may still
|
||||
/// change it).
|
||||
Future<void> _load() async {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final file = await DependenciesScope.of(context).files.pick(
|
||||
|
|
@ -64,16 +64,12 @@ class _CardTextSheetState() extends State<_CardTextSheet> {
|
|||
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);
|
||||
}
|
||||
final text = decodeCardText(file.bytes);
|
||||
setState(() {
|
||||
_text.text = text;
|
||||
_separator = guessSeparator(text, fileName: file.name);
|
||||
_unreadable = text.contains('\u0000');
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ class StudysetMarkdownExporter(
|
|||
throw ArgumentError.value(studysetId, 'studysetId', 'is no studyset');
|
||||
}
|
||||
final export = _Export(texts, style, _fileOf);
|
||||
await _studyset(export, root, all, depth: 1);
|
||||
await _studyset(export, root, all, depth: 1, seen: {});
|
||||
return (
|
||||
markdown: '${export.blocks.join('\n\n')}\n',
|
||||
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(
|
||||
_Export out,
|
||||
Studyset studyset,
|
||||
List<Studyset> all, {
|
||||
required int depth,
|
||||
required Set<String> seen,
|
||||
}) async {
|
||||
if (!seen.add(studyset.id)) {
|
||||
return;
|
||||
}
|
||||
final content = studyset.content;
|
||||
out.heading(depth, content.title);
|
||||
if (content.secondaryTitle case final secondary?
|
||||
|
|
@ -184,7 +191,7 @@ class StudysetMarkdownExporter(
|
|||
}
|
||||
|
||||
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)}',
|
||||
);
|
||||
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.
|
||||
|
|
@ -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
|
||||
/// underscores; null for none left.
|
||||
String? _safe(String? name) {
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
|
|||
texts: exportTexts(l10n),
|
||||
style: style,
|
||||
);
|
||||
} on Exception catch (_) {
|
||||
} on Object catch (_) {
|
||||
say(l10n.exportFailed);
|
||||
return;
|
||||
}
|
||||
|
|
@ -58,7 +58,14 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
|
|||
} else {
|
||||
final archive = Archive()..add(ArchiveFile.bytes('$name.md', markdown));
|
||||
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(
|
||||
name: '$name.zip',
|
||||
|
|
@ -66,7 +73,7 @@ Future<void> exportStudyset(BuildContext context, Studyset studyset) async {
|
|||
mimeType: 'application/zip',
|
||||
);
|
||||
}
|
||||
} on Exception catch (_) {
|
||||
} on Object catch (_) {
|
||||
say(l10n.exportFailed);
|
||||
return;
|
||||
}
|
||||
|
|
@ -121,3 +128,9 @@ String _fileStem(String title) {
|
|||
.replaceAll(RegExp(r'\s+'), ' ');
|
||||
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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:developer';
|
|||
import 'package:equatable/equatable.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/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_repository.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));
|
||||
});
|
||||
on<PathEditorEventStepMoved>(
|
||||
(event, emit) =>
|
||||
_paths.moveStep(_pathId, event.stepId, toIndex: event.toIndex),
|
||||
);
|
||||
on<PathEditorEventStepMoved>((event, emit) {
|
||||
// The list takes the new order at once; the stored one follows.
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:developer';
|
|||
|
||||
import 'package:equatable/equatable.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_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/learning_path/data/path_import.dart';
|
||||
|
|
@ -57,10 +58,24 @@ class PathListBloc(
|
|||
);
|
||||
}
|
||||
});
|
||||
on<PathListEventMoveRequested>(
|
||||
(event, emit) =>
|
||||
_paths.movePath(_studysetId, event.id, toIndex: event.toIndex),
|
||||
);
|
||||
on<PathListEventMoveRequested>((event, emit) {
|
||||
// The list takes the new order at once; the stored one follows.
|
||||
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 {
|
||||
final stepIds = {
|
||||
for (final step in await _paths.stepsOf(event.id)) step.id,
|
||||
|
|
|
|||
|
|
@ -77,20 +77,27 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
|
|||
_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(
|
||||
String studysetId,
|
||||
String id, {
|
||||
required int toIndex,
|
||||
Set<String> hidden = const {},
|
||||
}) async {
|
||||
final others = [
|
||||
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,
|
||||
positionAmong([for (final other in others) other.position], toIndex),
|
||||
);
|
||||
).entries) {
|
||||
await _gateway.updatePathPosition(moved, position);
|
||||
}
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
|
|
@ -181,20 +188,27 @@ class LearningPathRepository(final LearningPathGateway _gateway) {
|
|||
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(
|
||||
String pathId,
|
||||
String id, {
|
||||
required int toIndex,
|
||||
Set<String> hidden = const {},
|
||||
}) async {
|
||||
final others = [
|
||||
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));
|
||||
await _gateway.updateStepPosition(
|
||||
for (final MapEntry(key: moved, value: position) in placeAmong(
|
||||
others,
|
||||
toIndex,
|
||||
id,
|
||||
positionAmong([for (final other in others) other.position], toIndex),
|
||||
);
|
||||
).entries) {
|
||||
await _gateway.updateStepPosition(moved, position);
|
||||
}
|
||||
_changes.add(null);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@ class const SliverPathList({
|
|||
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) {
|
||||
return SliverCentered(
|
||||
padding: padding,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'dart:developer';
|
|||
|
||||
import 'package:equatable/equatable.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_repository.dart';
|
||||
import 'package:halkyon_learn_flutter/src/feature/note/model/note.dart';
|
||||
|
|
@ -16,10 +17,24 @@ class NoteListBloc(
|
|||
) extends Bloc<NoteListEvent, NoteListState> {
|
||||
this : super(const NoteListState()) {
|
||||
on<NoteListEventStarted>(_onStarted);
|
||||
on<NoteListEventMoveRequested>(
|
||||
(event, emit) =>
|
||||
_notes.move(_studysetId, event.id, toIndex: event.toIndex),
|
||||
);
|
||||
on<NoteListEventMoveRequested>((event, emit) {
|
||||
// The list takes the new order at once; the stored one follows.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -118,17 +118,27 @@ class NoteRepository(final NoteGateway _gateway) {
|
|||
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(
|
||||
String studysetId,
|
||||
String noteId, {
|
||||
required int toIndex,
|
||||
Set<String> hidden = const {},
|
||||
}) async {
|
||||
final others = [
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@ class const SliverNoteList({
|
|||
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) {
|
||||
return SliverCentered(
|
||||
padding: padding,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import 'dart:typed_data';
|
|||
import 'package:equatable/equatable.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/sync/fractional_position.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/note/data/note_repository.dart';
|
||||
|
|
@ -69,10 +70,24 @@ class PdfListBloc(
|
|||
await _import(emit, bytes, fileName, true);
|
||||
}
|
||||
});
|
||||
on<PdfListEventMoveRequested>(
|
||||
(event, emit) =>
|
||||
_pdfs.move(_studysetId, event.id, toIndex: event.toIndex),
|
||||
);
|
||||
on<PdfListEventMoveRequested>((event, emit) {
|
||||
// The list takes the new order at once; the stored one follows.
|
||||
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>(
|
||||
(event, emit) => _pdfs.rename(event.pdfId, event.title),
|
||||
);
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue