Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Import/export books as CSV file, import Goodreads CSV #335

Merged
10 changes: 9 additions & 1 deletion assets/translations/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -223,5 +223,13 @@
"dark_mode_amoled": "AMOLED dark mode",
"selected": "Selected",
"change_book_type": "Change book type",
"update_successful_message": "Updated successfully!"
"update_successful_message": "Updated successfully!",
"export_successful": "Export successful",
"openreads_backup": "Openreads backup",
"csv": "CSV",
"export_csv": "Export books as a CSV file",
"export_csv_description_1": "Covers are not exported",
"import_goodreads_csv": "Import Goodreads CSV",
"choose_not_finished_shelf": "Choose a shelf with not finished books:",
"import_successful": "Import successful"
}
228 changes: 228 additions & 0 deletions lib/core/helpers/backup/backup_export.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';

import 'package:archive/archive.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_storage/shared_storage.dart';

import 'package:openreads/core/helpers/backup/backup_helpers.dart';
import 'package:openreads/generated/locale_keys.g.dart';
import 'package:openreads/logic/bloc/challenge_bloc/challenge_bloc.dart';
import 'package:openreads/main.dart';

class BackupExport {
static createLocalBackupLegacyStorage(BuildContext context) async {
final tmpBackupPath = await prepareTemporaryBackup(context);
if (tmpBackupPath == null) return;

try {
// ignore: use_build_context_synchronously
final backupPath = await BackupGeneral.openFolderPicker(context);
final fileName = await _prepareBackupFileName();

final filePath = '$backupPath/$fileName';

File(filePath).writeAsBytesSync(File(tmpBackupPath).readAsBytesSync());

BackupGeneral.showInfoSnackbar(LocaleKeys.backup_successfull.tr());
} catch (e) {
BackupGeneral.showInfoSnackbar(e.toString());
}
}

static Future createLocalBackup(BuildContext context) async {
final tmpBackupPath = await prepareTemporaryBackup(context);
if (tmpBackupPath == null) return;

final selectedUriDir = await openDocumentTree();

if (selectedUriDir == null) {
return;
}

final fileName = await _prepareBackupFileName();

try {
createFileAsBytes(
selectedUriDir,
mimeType: '',
displayName: fileName,
bytes: File(tmpBackupPath).readAsBytesSync(),
);

BackupGeneral.showInfoSnackbar(LocaleKeys.backup_successfull.tr());
} catch (e) {
BackupGeneral.showInfoSnackbar(e.toString());
}
}

static Future<String?> prepareTemporaryBackup(BuildContext context) async {
try {
await bookCubit.getAllBooks(tags: true);

final books = await bookCubit.allBooks.first;
final listOfBookJSONs = List<String>.empty(growable: true);
final coverFiles = List<File>.empty(growable: true);

for (var book in books) {
// Making sure no covers are stored as JSON
final bookWithCoverNull = book.copyWithNullCover();

listOfBookJSONs.add(jsonEncode(bookWithCoverNull.toJSON()));

// Creating a list of current cover files
if (book.hasCover) {
// Check if cover file exists, if not then skip
if (!File('${appDocumentsDirectory.path}/${book.id}.jpg')
.existsSync()) {
continue;
}

final coverFile = File(
'${appDocumentsDirectory.path}/${book.id}.jpg',
);
coverFiles.add(coverFile);
}

await Future.delayed(const Duration(milliseconds: 50));
}

// ignore: use_build_context_synchronously
final challengeTargets = await _getChallengeTargets(context);

// ignore: use_build_context_synchronously
return await _writeTempBackupFile(
listOfBookJSONs,
challengeTargets,
coverFiles,
);
} catch (e) {
BackupGeneral.showInfoSnackbar(e.toString());

return null;
}
}

static Future<String?> _getChallengeTargets(BuildContext context) async {
if (!context.mounted) return null;

final state = BlocProvider.of<ChallengeBloc>(context).state;

if (state is SetChallengeState) {
return state.yearlyChallenges;
}

return null;
}

// Current backup version: 5
static Future<String?> _writeTempBackupFile(
List<String> listOfBookJSONs,
String? challengeTargets,
List<File>? coverFiles,
) async {
final data = listOfBookJSONs.join('@@@@@');
final fileName = await _prepareBackupFileName();
final tmpFilePath = '${appTempDirectory.path}/$fileName';

try {
// Saving books to temp file
File('${appTempDirectory.path}/books.backup').writeAsStringSync(data);

// Reading books temp file to memory
final booksBytes =
File('${appTempDirectory.path}/books.backup').readAsBytesSync();

final archivedBooks = ArchiveFile(
'books.backup',
booksBytes.length,
booksBytes,
);

// Prepare main archive
final archive = Archive();
archive.addFile(archivedBooks);

if (challengeTargets != null) {
// Saving challenges to temp file
File('${appTempDirectory.path}/challenges.backup')
.writeAsStringSync(challengeTargets);

// Reading challenges temp file to memory
final challengeTargetsBytes =
File('${appTempDirectory.path}/challenges.backup')
.readAsBytesSync();

final archivedChallengeTargets = ArchiveFile(
'challenges.backup',
challengeTargetsBytes.length,
challengeTargetsBytes,
);

archive.addFile(archivedChallengeTargets);
}

// Adding covers to the backup file
if (coverFiles != null && coverFiles.isNotEmpty) {
for (var coverFile in coverFiles) {
final coverBytes = coverFile.readAsBytesSync();

final archivedCover = ArchiveFile(
coverFile.path.split('/').last,
coverBytes.length,
coverBytes,
);

archive.addFile(archivedCover);
}
}

// Add info file
final info = await _prepareBackupInfo();
final infoBytes = utf8.encode(info);

final archivedInfo = ArchiveFile(
'info.txt',
infoBytes.length,
infoBytes,
);
archive.addFile(archivedInfo);

final encodedArchive = ZipEncoder().encode(archive);

if (encodedArchive == null) return null;

File(tmpFilePath).writeAsBytesSync(encodedArchive);

return tmpFilePath;
} catch (e) {
BackupGeneral.showInfoSnackbar(e.toString());

return null;
}
}

static Future<String> _getAppVersion() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();

return packageInfo.version;
}

static Future<String> _prepareBackupFileName() async {
final date = DateTime.now();
final backupDate =
'${date.year}_${date.month}_${date.day}-${date.hour}_${date.minute}_${date.second}';

return 'Openreads-$backupDate.backup';
}

static Future<String> _prepareBackupInfo() async {
final appVersion = await _getAppVersion();

return 'App version: $appVersion\nBackup version: 5';
}
}
127 changes: 127 additions & 0 deletions lib/core/helpers/backup/backup_general.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import 'dart:io';

import 'package:flutter/material.dart';

import 'package:easy_localization/easy_localization.dart';
import 'package:filesystem_picker/filesystem_picker.dart';
import 'package:openreads/main.dart';
import 'package:permission_handler/permission_handler.dart';

import 'package:openreads/generated/locale_keys.g.dart';

class BackupGeneral {
static showInfoSnackbar(String message) {
final snackBar = SnackBar(content: Text(message));
snackbarKey.currentState?.showSnackBar(snackBar);
}

static Future<bool> requestStoragePermission(BuildContext context) async {
if (await Permission.storage.isPermanentlyDenied) {
// ignore: use_build_context_synchronously
_openSystemSettings(context);
return false;
} else if (await Permission.storage.status.isDenied) {
if (await Permission.storage.request().isGranted) {
return true;
} else {
// ignore: use_build_context_synchronously
_openSystemSettings(context);
return false;
}
} else if (await Permission.storage.status.isGranted) {
return true;
}
return false;
}

static Future<String?> openFolderPicker(BuildContext context) async {
if (!context.mounted) return null;

return await FilesystemPicker.open(
context: context,
title: LocaleKeys.choose_backup_folder.tr(),
pickText: LocaleKeys.save_file_to_this_folder.tr(),
fsType: FilesystemType.folder,
rootDirectory: Directory('/storage/emulated/0/'),
contextActions: [
FilesystemPickerNewFolderContextAction(
icon: Icon(
Icons.create_new_folder,
color: Theme.of(context).colorScheme.primary,
size: 24,
),
),
],
theme: FilesystemPickerTheme(
backgroundColor: Theme.of(context).colorScheme.surface,
pickerAction: FilesystemPickerActionThemeData(
foregroundColor: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surface,
),
fileList: FilesystemPickerFileListThemeData(
iconSize: 24,
upIconSize: 24,
checkIconSize: 24,
folderTextStyle: const TextStyle(fontSize: 16),
),
topBar: FilesystemPickerTopBarThemeData(
backgroundColor: Theme.of(context).colorScheme.surface,
titleTextStyle: const TextStyle(fontSize: 18),
elevation: 0,
shadowColor: Colors.transparent,
),
),
);
}

static Future<String?> openFilePicker(
BuildContext context, {
List<String> allowedExtensions = const ['.backup', '.zip', '.png'],
}) async {
if (!context.mounted) return null;

return await FilesystemPicker.open(
context: context,
title: LocaleKeys.choose_backup_file.tr(),
pickText: LocaleKeys.use_this_file.tr(),
fsType: FilesystemType.file,
rootDirectory: Directory('/storage/emulated/0/'),
fileTileSelectMode: FileTileSelectMode.wholeTile,
allowedExtensions: allowedExtensions,
theme: FilesystemPickerTheme(
backgroundColor: Theme.of(context).colorScheme.surface,
fileList: FilesystemPickerFileListThemeData(
iconSize: 24,
upIconSize: 24,
checkIconSize: 24,
folderTextStyle: const TextStyle(fontSize: 16),
),
topBar: FilesystemPickerTopBarThemeData(
titleTextStyle: const TextStyle(fontSize: 18),
shadowColor: Colors.transparent,
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
);
}

static _openSystemSettings(BuildContext context) {
if (!context.mounted) return;

ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
LocaleKeys.need_storage_permission.tr(),
),
action: SnackBarAction(
label: LocaleKeys.open_settings.tr(),
onPressed: () {
if (context.mounted) {
openAppSettings();
}
},
),
),
);
}
}
5 changes: 5 additions & 0 deletions lib/core/helpers/backup/backup_helpers.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export 'backup_export.dart';
export 'backup_general.dart';
export 'backup_import.dart';
export 'csv_export.dart';
export 'csv_goodreads_import.dart';
Loading