Skip to content

Commit

Permalink
Merge pull request #335 from mateusz-bak/175-feature_request-add-impo…
Browse files Browse the repository at this point in the history
…rt-and-export-csv-feature

Import/export books as CSV file, import Goodreads CSV
  • Loading branch information
mateusz-bak authored Sep 26, 2023
2 parents 4fd2123 + 3fedc55 commit f3b45bc
Show file tree
Hide file tree
Showing 17 changed files with 1,974 additions and 1,190 deletions.
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

0 comments on commit f3b45bc

Please sign in to comment.