Export Series

This commit is contained in:
2025-11-24 23:22:58 +01:00
parent 4777fe49f9
commit eb3419aea6
6 changed files with 1034 additions and 58 deletions
@@ -0,0 +1,97 @@
import '../../../core/status.dart';
import 'episode_model.dart';
import 'series_exporter_stub.dart'
if (dart.library.html) 'series_exporter_web.dart'
if (dart.library.io) 'series_exporter_io.dart';
SeriesExporter createSeriesExporter() => getSeriesExporter();
class SeriesExportRow {
final String title;
final int? year;
final String? seriesStatus;
final bool? cliffhanger;
final String? resolution;
final String? downloadPath;
final int seasons;
final int episodesInit;
final int episodesProgress;
final int episodesDone;
final Map<int, Map<int, ItemStatus>>? episodesBySeason; // optional per-episode status
int get episodesTotal => episodesInit + episodesProgress + episodesDone;
SeriesExportRow({
required this.title,
required this.year,
required this.seriesStatus,
required this.cliffhanger,
required this.resolution,
required this.downloadPath,
required this.seasons,
required this.episodesInit,
required this.episodesProgress,
required this.episodesDone,
this.episodesBySeason,
});
factory SeriesExportRow.fromGroup(Map<int, List<EpisodeItem>> bySeason,
{bool includeEpisodeStatus = false}) {
String title = '';
int? year;
String? status;
bool? cliff;
String? resolution;
String? download;
int init = 0, prog = 0, done = 0;
final perSeason = <int, Map<int, ItemStatus>>{};
for (final eps in bySeason.values) {
for (final e in eps) {
title = e.showName;
year ??= e.firstAirYear;
status ??= e.showStatus;
cliff ??= e.showCliffhanger;
resolution ??= e.resolution;
download ??= e.downloadPath;
if (includeEpisodeStatus) {
perSeason.putIfAbsent(e.seasonNumber, () => {})[e.episodeNumber] = e.status;
}
switch (e.status) {
case ItemStatus.Init:
init++;
break;
case ItemStatus.Progress:
prog++;
break;
case ItemStatus.Done:
done++;
break;
}
}
}
return SeriesExportRow(
title: title,
year: year,
seriesStatus: status,
cliffhanger: cliff,
resolution: resolution,
downloadPath: download,
seasons: bySeason.length,
episodesInit: init,
episodesProgress: prog,
episodesDone: done,
episodesBySeason: includeEpisodeStatus ? perSeason : null,
);
}
}
abstract class SeriesExporter {
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter});
Future<String?> exportSeries(
List<SeriesExportRow> rows, {
required bool asXlsx,
ItemStatus? filter,
String? path,
});
}
@@ -0,0 +1,270 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/services.dart';
import 'package:syncfusion_flutter_xlsio/xlsio.dart' as xlsio;
import '../../../core/status.dart';
import 'series_exporter.dart';
SeriesExporter getSeriesExporter() => _SeriesExporterIo();
class _SeriesExporterIo implements SeriesExporter {
@override
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) async {
final name = _defaultFilename(filter, asXlsx);
String? savePath;
try {
savePath = await FilePicker.platform.saveFile(
dialogTitle: 'Export speichern',
fileName: name,
type: FileType.custom,
allowedExtensions: [asXlsx ? 'xlsx' : 'csv'],
);
} on PlatformException catch (e) {
throw Exception('Speicherdialog fehlgeschlagen: ${e.code}');
}
return savePath;
}
@override
Future<String?> exportSeries(
List<SeriesExportRow> rows, {
required bool asXlsx,
ItemStatus? filter,
String? path,
}) async {
final savePath = path ?? await chooseSavePath(asXlsx: asXlsx, filter: filter);
if (savePath == null) return null;
if (asXlsx) {
await _writeXlsx(savePath, rows);
} else {
await _writeCsv(savePath, rows);
}
return savePath;
}
Future<void> _writeCsv(String path, List<SeriesExportRow> rows) async {
final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList();
final hasEpisodes = rowsWithEpisodes.isNotEmpty;
final episodeSeasons = <int>{};
if (hasEpisodes) {
for (final r in rowsWithEpisodes) {
episodeSeasons.addAll(r.episodesBySeason!.keys);
}
}
final seasonList = episodeSeasons.toList()..sort();
final header = [
'Titel',
'Jahr',
'Serienstatus',
'Cliffhanger',
'Aufloesung',
'DownloadPfad',
'Staffeln',
'Init',
'Progress',
'Done',
'Episoden Gesamt',
for (final s in seasonList) ...[
'S$s',
for (int ep = 1; ep <= _maxEpForSeason(rowsWithEpisodes, s); ep++) 'E$ep',
],
];
final csvRows = <List<String>>[];
// First header row: season labels over episode columns
final seasonHeader = <String>[];
seasonHeader.addAll(header.take(11));
for (final s in seasonList) {
seasonHeader.add('Staffel $s');
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
for (int ep = 1; ep <= maxEp; ep++) {
seasonHeader.add('');
}
}
csvRows.add(seasonHeader);
// Second header row: episode numbers (aligned) with blanks for season column
final epHeader = <String>[];
epHeader.addAll(header.take(11));
for (final s in seasonList) {
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
epHeader.add(''); // season label column already in first header
for (int ep = 1; ep <= maxEp; ep++) {
epHeader.add('E$ep');
}
}
csvRows.add(epHeader);
for (final r in rows) {
final row = [
r.title,
r.year?.toString() ?? '',
r.seriesStatus ?? '',
r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein'),
r.resolution ?? '',
r.downloadPath ?? '',
r.seasons.toString(),
r.episodesInit.toString(),
r.episodesProgress.toString(),
r.episodesDone.toString(),
r.episodesTotal.toString(),
];
if (hasEpisodes) {
for (final s in seasonList) {
final bySeason = r.episodesBySeason ?? const {};
final eps = bySeason[s] ?? const {};
row.add('S$s');
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
for (int ep = 1; ep <= maxEp; ep++) {
final st = eps[ep];
row.add(_statusCode(st));
}
}
}
csvRows.add(row);
}
final content = _toCsv(csvRows);
final file = File(path);
await file.writeAsString(content, flush: true);
}
Future<void> _writeXlsx(String path, List<SeriesExportRow> rows) async {
final wb = xlsio.Workbook();
final sheet = wb.worksheets[0];
sheet.name = 'Serien';
final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList();
final hasEpisodes = rowsWithEpisodes.isNotEmpty;
final episodeSeasons = <int>{};
if (hasEpisodes) {
for (final r in rowsWithEpisodes) {
episodeSeasons.addAll(r.episodesBySeason!.keys);
}
}
final seasonList = episodeSeasons.toList()..sort();
final headers = [
'Titel',
'Jahr',
'Serienstatus',
'Cliffhanger',
'Aufloesung',
'DownloadPfad',
'Staffeln',
'Init',
'Progress',
'Done',
'Episoden Gesamt',
];
var col = 1;
for (final h in headers) {
sheet.getRangeByIndex(1, col).setText(h);
col++;
}
int headerRowSpan = 1;
if (hasEpisodes) {
// Two header rows: season label and episode numbers
int rowSeason = 1;
int rowEpisode = 2;
// Shift data rows by +1 to accommodate extra header row
headerRowSpan = 2;
col = headers.length + 1;
for (final s in seasonList) {
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
sheet.getRangeByIndex(rowSeason, col).setText('Season $s');
if (maxEp > 1) {
sheet.getRangeByIndex(rowSeason, col, rowSeason, col + maxEp - 1).merge();
}
for (int ep = 1; ep <= maxEp; ep++) {
sheet.getRangeByIndex(rowEpisode, col + ep - 1).setText('E$ep');
}
col += maxEp;
}
}
for (var i = 0; i < rows.length; i++) {
final r = rows[i];
final row = i + headerRowSpan + 1;
sheet.getRangeByIndex(row, 1).setText(r.title);
if (r.year != null) sheet.getRangeByIndex(row, 2).setNumber(r.year!.toDouble());
sheet.getRangeByIndex(row, 3).setText(r.seriesStatus ?? '');
sheet.getRangeByIndex(row, 4).setText(r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein'));
sheet.getRangeByIndex(row, 5).setText(r.resolution ?? '');
sheet.getRangeByIndex(row, 6).setText(r.downloadPath ?? '');
sheet.getRangeByIndex(row, 7).setNumber(r.seasons.toDouble());
sheet.getRangeByIndex(row, 8).setNumber(r.episodesInit.toDouble());
sheet.getRangeByIndex(row, 9).setNumber(r.episodesProgress.toDouble());
sheet.getRangeByIndex(row, 10).setNumber(r.episodesDone.toDouble());
sheet.getRangeByIndex(row, 11).setNumber(r.episodesTotal.toDouble());
if (hasEpisodes) {
var colOffset = headers.length + 1;
final epsBySeason = r.episodesBySeason ?? const {};
for (final s in seasonList) {
final eps = epsBySeason[s] ?? const {};
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
for (int ep = 1; ep <= maxEp; ep++) {
final st = eps[ep];
sheet.getRangeByIndex(row, colOffset + ep - 1).setText(_statusCode(st));
}
colOffset += maxEp;
}
}
}
final bytes = wb.saveAsStream();
wb.dispose();
final file = File(path);
await file.writeAsBytes(bytes, flush: true);
}
String _defaultFilename(ItemStatus? filter, bool asXlsx) {
final safeFilter = (filter?.name ?? 'alle').replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
final ts = DateTime.now();
String two(int v) => v.toString().padLeft(2, '0');
final stamp = '${ts.year}${two(ts.month)}${two(ts.day)}_${two(ts.hour)}${two(ts.minute)}${two(ts.second)}';
return 'series_${safeFilter}_$stamp.${asXlsx ? 'xlsx' : 'csv'}';
}
String _toCsv(List<List<String>> rows) {
final buffer = StringBuffer();
for (var i = 0; i < rows.length; i++) {
final row = rows[i];
for (var j = 0; j < row.length; j++) {
buffer.write(_escape(row[j]));
if (j < row.length - 1) buffer.write(',');
}
if (i < rows.length - 1) buffer.writeln();
}
return buffer.toString();
}
String _escape(String v) {
final needsQuotes = v.contains(',') || v.contains('"') || v.contains('\n') || v.contains('\r');
final escaped = v.replaceAll('"', '""');
return needsQuotes ? '"$escaped"' : escaped;
}
int _maxEpForSeason(List<SeriesExportRow> rows, int season) {
var maxEp = 0;
for (final r in rows) {
final eps = r.episodesBySeason?[season];
if (eps != null && eps.isNotEmpty) {
final m = eps.keys.reduce((a, b) => a > b ? a : b);
if (m > maxEp) maxEp = m;
}
}
return maxEp;
}
String _statusCode(ItemStatus? st) {
switch (st) {
case ItemStatus.Init:
return 'I';
case ItemStatus.Progress:
return 'P';
case ItemStatus.Done:
return 'D';
default:
return '';
}
}
}
@@ -0,0 +1,17 @@
import '../../../core/status.dart';
import 'series_exporter.dart';
SeriesExporter getSeriesExporter() => _StubSeriesExporter();
class _StubSeriesExporter implements SeriesExporter {
@override
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) {
return Future.error(UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'));
}
@override
Future<String?> exportSeries(List<SeriesExportRow> rows,
{required bool asXlsx, ItemStatus? filter, String? path}) {
return Future.error(UnsupportedError('Export wird auf dieser Plattform nicht unterstützt.'));
}
}
@@ -0,0 +1,240 @@
import 'dart:convert';
import 'dart:typed_data';
import 'dart:html' as html;
import '../../../core/status.dart';
import 'series_exporter.dart';
import 'package:syncfusion_flutter_xlsio/xlsio.dart' as xlsio;
SeriesExporter getSeriesExporter() => _SeriesExporterWeb();
class _SeriesExporterWeb implements SeriesExporter {
@override
Future<String?> chooseSavePath({required bool asXlsx, ItemStatus? filter}) async {
// Browser decides; we only provide filename.
return _defaultFilename(filter, asXlsx);
}
@override
Future<String?> exportSeries(
List<SeriesExportRow> rows, {
required bool asXlsx,
ItemStatus? filter,
String? path,
}) async {
final filename = path ?? _defaultFilename(filter, asXlsx);
final bytes = asXlsx ? _buildXlsxBytes(rows) : _buildCsvBytes(rows);
_download(bytes, filename);
return filename;
}
Uint8List _buildCsvBytes(List<SeriesExportRow> rows) {
final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList();
final hasEpisodes = rowsWithEpisodes.isNotEmpty;
final episodeSeasons = <int>{};
if (hasEpisodes) {
for (final r in rowsWithEpisodes) {
episodeSeasons.addAll(r.episodesBySeason!.keys);
}
}
final seasonList = episodeSeasons.toList()..sort();
final csvRows = <List<String>>[];
// Header rows
final baseHeader = ['Titel', 'Jahr', 'Serienstatus', 'Cliffhanger', 'Aufloesung', 'DownloadPfad', 'Staffeln', 'Init', 'Progress', 'Done', 'Episoden Gesamt'];
final seasonHeader = <String>[]..addAll(baseHeader);
final epHeader = <String>[]..addAll(baseHeader);
if (hasEpisodes) {
for (final s in seasonList) {
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
seasonHeader.add('Season $s');
for (int ep = 1; ep <= maxEp; ep++) {
seasonHeader.add('');
epHeader.add('E$ep');
}
}
}
csvRows.add(seasonHeader);
if (hasEpisodes) {
csvRows.add(epHeader);
}
for (final r in rows) {
final row = [
r.title,
r.year?.toString() ?? '',
r.seriesStatus ?? '',
r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein'),
r.resolution ?? '',
r.downloadPath ?? '',
r.seasons.toString(),
r.episodesInit.toString(),
r.episodesProgress.toString(),
r.episodesDone.toString(),
r.episodesTotal.toString(),
];
if (hasEpisodes) {
for (final s in seasonList) {
final eps = r.episodesBySeason?[s] ?? const {};
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
row.add('S$s');
for (int ep = 1; ep <= maxEp; ep++) {
row.add(_statusCode(eps[ep]));
}
}
}
csvRows.add(row);
}
final csv = _toCsv(csvRows);
return Uint8List.fromList(utf8.encode(csv));
}
Uint8List _buildXlsxBytes(List<SeriesExportRow> rows) {
final rowsWithEpisodes = rows.where((r) => r.episodesBySeason != null).toList();
final hasEpisodes = rowsWithEpisodes.isNotEmpty;
final episodeSeasons = <int>{};
if (hasEpisodes) {
for (final r in rowsWithEpisodes) {
episodeSeasons.addAll(r.episodesBySeason!.keys);
}
}
final seasonList = episodeSeasons.toList()..sort();
final wb = xlsio.Workbook();
final sheet = wb.worksheets[0];
sheet.name = 'Serien';
final headers = [
'Titel',
'Jahr',
'Serienstatus',
'Cliffhanger',
'Aufloesung',
'DownloadPfad',
'Staffeln',
'Init',
'Progress',
'Done',
'Episoden Gesamt',
];
var col = 1;
for (final h in headers) {
sheet.getRangeByIndex(1, col).setText(h);
col++;
}
int headerRowSpan = 1;
if (hasEpisodes) {
headerRowSpan = 2;
int rowSeason = 1;
int rowEpisode = 2;
col = headers.length + 1;
for (final s in seasonList) {
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
sheet.getRangeByIndex(rowSeason, col).setText('Season $s');
if (maxEp > 1) {
sheet.getRangeByIndex(rowSeason, col, rowSeason, col + maxEp - 1).merge();
}
for (int ep = 1; ep <= maxEp; ep++) {
sheet.getRangeByIndex(rowEpisode, col + ep - 1).setText('E$ep');
}
col += maxEp;
}
}
for (var i = 0; i < rows.length; i++) {
final r = rows[i];
final row = i + headerRowSpan + 1;
sheet.getRangeByIndex(row, 1).setText(r.title);
if (r.year != null) sheet.getRangeByIndex(row, 2).setNumber(r.year!.toDouble());
sheet.getRangeByIndex(row, 3).setText(r.seriesStatus ?? '');
sheet.getRangeByIndex(row, 4).setText(r.cliffhanger == null ? '' : (r.cliffhanger! ? 'ja' : 'nein'));
sheet.getRangeByIndex(row, 5).setText(r.resolution ?? '');
sheet.getRangeByIndex(row, 6).setText(r.downloadPath ?? '');
sheet.getRangeByIndex(row, 7).setNumber(r.seasons.toDouble());
sheet.getRangeByIndex(row, 8).setNumber(r.episodesInit.toDouble());
sheet.getRangeByIndex(row, 9).setNumber(r.episodesProgress.toDouble());
sheet.getRangeByIndex(row, 10).setNumber(r.episodesDone.toDouble());
sheet.getRangeByIndex(row, 11).setNumber(r.episodesTotal.toDouble());
if (hasEpisodes) {
var colOffset = headers.length + 1;
final epsBySeason = r.episodesBySeason ?? const {};
for (final s in seasonList) {
final eps = epsBySeason[s] ?? const {};
final maxEp = _maxEpForSeason(rowsWithEpisodes, s);
for (int ep = 1; ep <= maxEp; ep++) {
sheet.getRangeByIndex(row, colOffset + ep - 1).setText(_statusCode(eps[ep]));
}
colOffset += maxEp;
}
}
}
final bytes = wb.saveAsStream();
wb.dispose();
return Uint8List.fromList(bytes);
}
void _download(Uint8List bytes, String filename) {
final blob = html.Blob([bytes]);
final url = html.Url.createObjectUrlFromBlob(blob);
final anchor = html.AnchorElement(href: url)
..download = filename
..style.display = 'none';
html.document.body?.append(anchor);
anchor.click();
anchor.remove();
html.Url.revokeObjectUrl(url);
}
String _defaultFilename(ItemStatus? filter, bool asXlsx) {
final safeFilter = (filter?.name ?? 'alle').replaceAll(RegExp(r'[^A-Za-z0-9_-]'), '_');
final ts = DateTime.now();
String two(int v) => v.toString().padLeft(2, '0');
final stamp = '${ts.year}${two(ts.month)}${two(ts.day)}_${two(ts.hour)}${two(ts.minute)}${two(ts.second)}';
return 'series_${safeFilter}_$stamp.${asXlsx ? 'xlsx' : 'csv'}';
}
int _maxEpForSeason(List<SeriesExportRow> rows, int season) {
var maxEp = 0;
for (final r in rows) {
final eps = r.episodesBySeason?[season];
if (eps != null && eps.isNotEmpty) {
final m = eps.keys.reduce((a, b) => a > b ? a : b);
if (m > maxEp) maxEp = m;
}
}
return maxEp;
}
String _statusCode(ItemStatus? st) {
switch (st) {
case ItemStatus.Init:
return 'I';
case ItemStatus.Progress:
return 'P';
case ItemStatus.Done:
return 'D';
default:
return '';
}
}
String _toCsv(List<List<String>> rows) {
final buffer = StringBuffer();
for (var i = 0; i < rows.length; i++) {
final row = rows[i];
for (var j = 0; j < row.length; j++) {
buffer.write(_escape(row[j]));
if (j < row.length - 1) buffer.write(',');
}
if (i < rows.length - 1) buffer.writeln();
}
return buffer.toString();
}
String _escape(String v) {
final needsQuotes = v.contains(',') || v.contains('"') || v.contains('\n') || v.contains('\r');
final escaped = v.replaceAll('"', '""');
return needsQuotes ? '"$escaped"' : escaped;
}
}