Zip backup (#933)

* add remote zip/unzip

* reimplement: remote backup

* fix: close backup request

* get response for remote zip

* add zip drive backup

* add zip drive restore

* fix: categories are not restored

* change scope to drive.file

* add description for backup action

* refactor: Backup types
This commit is contained in:
nyagami
2024-01-31 19:19:30 +07:00
committed by GitHub
parent 6c20d1855d
commit ad2412eb62
17 changed files with 561 additions and 990 deletions
@@ -1,17 +1,27 @@
package com.rajarsheechatterjee.ZipArchive;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.ReadableMap;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.Map;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
public class ZipArchive extends ReactContextBaseJavaModule {
ZipArchive(ReactApplicationContext context) throws Exception {
@@ -23,36 +33,141 @@ public class ZipArchive extends ReactContextBaseJavaModule {
public String getName() {
return "ZipArchive";
}
public String escapeFilePath(String filePath){
private String escapeFilePath(String filePath){
return filePath.replaceAll(":", "\uA789");
}
@ReactMethod
public void unzip(String epubFilePath, String epubDirPath, Promise promise) {
try{
ZipInputStream zis = new ZipInputStream(new FileInputStream(epubFilePath));
ZipEntry zipEntry;
int len;
byte[] buffer = new byte[4096];
while ((zipEntry = zis.getNextEntry()) != null) {
String escapedFilePath = this.escapeFilePath(zipEntry.getName());
File newFile = new File(epubDirPath, escapedFilePath);
newFile.getParentFile().mkdirs();
FileOutputStream fos = new FileOutputStream(newFile);
boolean isWritten = false; // ignore folder entry;
while ((len = zis.read(buffer)) > 0) {
isWritten = true;
fos.write(buffer, 0, len);
}
fos.close();
if(!isWritten) newFile.delete();
private void unzipProcess(ZipInputStream zis, String distDirPath) throws Exception {
ZipEntry zipEntry;
int len;
byte[] buffer = new byte[4096];
while ((zipEntry = zis.getNextEntry()) != null) {
String escapedFilePath = this.escapeFilePath(zipEntry.getName());
File newFile = new File(distDirPath, escapedFilePath);
newFile.getParentFile().mkdirs();
FileOutputStream fos = new FileOutputStream(newFile);
boolean isWritten = false; // ignore folder entry;
while ((len = zis.read(buffer)) > 0) {
isWritten = true;
fos.write(buffer, 0, len);
}
zis.closeEntry();
zis.close();
promise.resolve(null);
} catch (Exception e) {
promise.reject(e.getCause());
fos.close();
if(!isWritten) newFile.delete();
}
zis.closeEntry();
}
@ReactMethod
public void unzip(String sourceFilePath, String distDirPath, Promise promise) {
new Thread(new Runnable() {
@Override
public void run() {
try{
ZipInputStream zis = new ZipInputStream(new FileInputStream(sourceFilePath));
unzipProcess(zis, distDirPath);
zis.close();
promise.resolve(null);
} catch (Exception e) {
promise.reject(e.getCause());
}
}
}).start();
}
@ReactMethod
public void remoteUnzip(String distDirPath, String _url, @Nullable ReadableMap headers, Promise promise){
new Thread(new Runnable() {
@Override
public void run() {
try {
URL url = new URL(_url);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
if(headers != null){
Iterator<Map.Entry<String, Object>> it = headers.getEntryIterator();
while (it.hasNext()){
Map.Entry<String, Object> entry = it.next();
connection.setRequestProperty(entry.getKey(), entry.getValue().toString());
}
}
ZipInputStream zis = new ZipInputStream(connection.getInputStream());
unzipProcess(zis, distDirPath);
connection.disconnect();
promise.resolve(null);
}catch (Exception e){
promise.reject(e.getCause());
}
}
}).start();
}
private ArrayList<String> walkDir(String path) {
ArrayList<String> res = new ArrayList<>();
File node = new File(path);
if(node.isFile()){
res.add(path);
}else{
String[] children = node.list();
if(children != null){
for(String filename: children){
ArrayList<String> childPaths = walkDir(new File(path, filename).toString());
res.addAll(childPaths);
}
}
}
return res;
}
private void zipProcess(String sourceDirPath, ZipOutputStream zos) throws Exception{
ArrayList<String> paths = walkDir(sourceDirPath);
byte[] buffer = new byte[4096];
int len;
for(String path: paths){
ZipEntry zipEntry = new ZipEntry(path.replace(sourceDirPath + "/", ""));
zos.putNextEntry(zipEntry);
try (FileInputStream fis = new FileInputStream(path)) {
while ((len = fis.read(buffer)) > 0) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
}
zos.close();
}
@ReactMethod
public void remoteZip(String sourceDirPath, String _url, @Nullable ReadableMap headers, Promise promise){
new Thread(new Runnable() {
@Override
public void run() {
try {
URL url = new URL(_url);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
if(headers != null){
Iterator<Map.Entry<String, Object>> it = headers.getEntryIterator();
while (it.hasNext()){
Map.Entry<String, Object> entry = it.next();
connection.setRequestProperty(entry.getKey(), entry.getValue().toString());
}
}
ZipOutputStream zos = new ZipOutputStream(connection.getOutputStream());
zipProcess(sourceDirPath, zos);
if(connection.getResponseCode() == 200){
InputStream is = connection.getInputStream();
ByteArrayOutputStream result = new ByteArrayOutputStream();
byte[] buffer = new byte[1024];
for (int length; (length = is.read(buffer)) != -1; ) {
result.write(buffer, 0, length);
}
is.close();
connection.disconnect();
promise.resolve(result.toString());
}else{
throw new Exception("Network request failed");
}
}catch (Exception e){
promise.reject(e.getCause());
}
}
}).start();
}
}
+1 -9
View File
@@ -1,4 +1,4 @@
import { create, download, getJson, list } from './request';
import { create, list } from './request';
import { DriveCreateRequestData, DriveFile } from './types';
const LNREADER_DRIVE_MARK = '(Do not change this!) LNReader-Drive';
@@ -101,11 +101,3 @@ export const readDir = async (parentId: string, marked?: boolean) => {
}
return fileList;
};
export const readFile = async (file: DriveFile, type: 'json' | 'media') => {
if (type === 'json') {
return getJson(file.id);
} else {
return download(file);
}
};
+47 -26
View File
@@ -1,13 +1,12 @@
import { GoogleSignin } from '@react-native-google-signin/google-signin';
import { downloadFile, mkdir, exists } from 'react-native-fs';
import {
DriveCreateRequestData,
DriveFile,
DriveReponse,
DriveRequestParams,
} from './types';
import { AppDownloadFolder } from '@utils/constants/download';
import { PATH_SEPARATOR } from '@api/constants';
import ZipArchive from '@native/ZipArchive';
const BASE_URL = 'https://www.googleapis.com/drive/v3/files';
const MEDIA_UPLOAD_URL = 'https://www.googleapis.com/upload/drive/v3/files';
@@ -83,35 +82,57 @@ export const create = async (
}).then(res => res.json());
};
export const getJson = async (id: string) => {
export const updateMetadata = async (
fileId: string,
fileMetaData: Partial<DriveFile>,
oldParent?: string,
) => {
const { accessToken } = await GoogleSignin.getTokens();
const url = BASE_URL + '/' + id + '?alt=media';
const url =
BASE_URL +
'/' +
fileId +
'?' +
buildParams({
addParents: fileMetaData.parents?.[0],
removeParents: oldParent,
});
return fetch(url, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
}).then(res => res.json());
};
export const download = async (file: DriveFile) => {
const { accessToken } = await GoogleSignin.getTokens();
const url = BASE_URL + '/' + file.id + '?alt=media';
const regex = new RegExp(`${PATH_SEPARATOR}`, 'g');
const filePath = AppDownloadFolder + '/' + file.name.replace(regex, '/');
const dirPath = filePath.split('/').slice(0, -1).join('/');
return exists(filePath).then(existed => {
if (!existed) {
return mkdir(dirPath).then(() => {
return downloadFile({
fromUrl: url,
toFile: filePath,
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
},
});
});
}
body: JSON.stringify({
'name': fileMetaData.name,
'mimeType': fileMetaData.mimeType,
}),
});
};
export const uploadMedia = async (
sourceDirPath: string,
): Promise<DriveFile> => {
const { accessToken } = await GoogleSignin.getTokens();
const params: DriveRequestParams = {
fields: 'id, parents',
uploadType: 'media',
};
const url = MEDIA_UPLOAD_URL + '?' + buildParams(params);
const response = await ZipArchive.remoteZip(sourceDirPath, url, {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
});
return JSON.parse(response);
};
export const download = async (file: DriveFile, distDirPath: string) => {
const { accessToken } = await GoogleSignin.getTokens();
const url = BASE_URL + '/' + file.id + '?alt=media';
return ZipArchive.remoteUnzip(distDirPath, url, {
Authorization: `Bearer ${accessToken}`,
Accept: 'application/json',
});
};
+2
View File
@@ -22,6 +22,8 @@ export interface DriveRequestParams {
fields?: string;
pageToken?: string;
uploadType?: string; // only for upload
addParents?: string;
removeParents?: string;
}
export interface DriveCreateRequestData {
+17 -72
View File
@@ -1,8 +1,6 @@
import { PATH_SEPARATOR } from '@api/constants';
import { BackupPackage } from '@services/backup/types';
import { AppDownloadFolder } from '@utils/constants/download';
import ZipArchive from '@native/ZipArchive';
import { fetchTimeout } from '@utils/fetch/fetch';
import { downloadFile, mkdir } from 'react-native-fs';
const commonHeaders = {
'Connection': 'keep-alive',
@@ -11,80 +9,27 @@ const commonHeaders = {
'Accept-Encoding': 'gzip, deflate, br',
};
export const upload = (host: string, data: BackupPackage) => {
const url = host + '/' + 'upload';
const body = new FormData();
data.name = data.name.replace(/\//g, PATH_SEPARATOR);
body.append('metadata', JSON.stringify(data));
if (data.mimeType === 'application/json') {
body.append('media', {
string: data.content,
type: data.mimeType,
});
} else {
body.append('media', {
name: data.name,
uri: data.content,
type: data.mimeType,
});
}
return fetchTimeout(url, {
method: 'POST',
headers: commonHeaders,
body,
});
export const upload = (
host: string,
backupFolder: string,
filename: string,
sourceDirPath: string,
) => {
const url = `${host}/upload/${backupFolder}${PATH_SEPARATOR}${filename}`;
return ZipArchive.remoteZip(sourceDirPath, url, {});
};
export const list = (host: string, folderTree: string[]): Promise<string[]> => {
const url = host + '/' + 'list';
const body = new FormData();
body.append('metadata', JSON.stringify({ folderTree }));
return fetchTimeout(url, {
method: 'POST',
headers: commonHeaders,
body,
}).then(res => res.json());
};
export const getJson = (host: string, folderTree: string[], name: string) => {
const url = host + '/' + 'download' + '/' + folderTree.join('/') + '/' + name;
return fetchTimeout(url, {
headers: commonHeaders,
}).then(res => res.json());
export const list = (host: string): Promise<string[]> => {
const url = host + '/list';
return fetchTimeout(url, { headers: commonHeaders }).then(res => res.json());
};
export const download = async (
host: string,
folderTree: string[],
name: string,
backupFolder: string,
filename: string,
distDirPath: string,
) => {
const url = host + '/' + 'download' + '/' + folderTree.join('/') + '/' + name;
const regex = new RegExp(`${PATH_SEPARATOR}`, 'g');
const filePath = AppDownloadFolder + '/' + name.replace(regex, '/');
const dirPath = filePath.split('/').slice(0, -1).join('/');
await mkdir(dirPath).then(() => {
return downloadFile({
fromUrl: url,
toFile: filePath,
headers: commonHeaders,
}).promise;
});
};
export const exists = (
host: string,
folderTree: string[],
name: string,
): Promise<boolean> => {
const url = host + '/' + 'exists';
const body = new FormData();
body.append('metadata', JSON.stringify({ folderTree, name }));
return fetchTimeout(url, {
method: 'POST',
headers: commonHeaders,
body,
})
.then(res => res.json())
.then(data => data.exists);
const url = `${host}/download/${backupFolder}${PATH_SEPARATOR}${filename}`;
return ZipArchive.remoteUnzip(distDirPath, url, {});
};
+2
View File
@@ -158,6 +158,8 @@ export const _restoreCategory = (category: BackupCategory) => {
category.id,
category.sort,
]);
});
db.transaction(tx => {
tx.executeSql('INSERT INTO Category (id, name, sort) VALUES (?, ?, ?)', [
category.id,
category.name,
+11 -2
View File
@@ -1,7 +1,16 @@
import { SourceNovel } from '@plugins/types';
import { NativeModules } from 'react-native';
interface ZipArchiveInterface {
unzip: (epubFilePath: string, epubDirPath: string) => Promise<SourceNovel>;
unzip: (sourceFilePath: string, distDirPath: string) => Promise<void>;
remoteUnzip: (
distDirPath: string,
url: string,
headers: Record<string, string>,
) => Promise<void>;
remoteZip: (
sourceDirPath: string,
url: string,
headers: Record<string, string>,
) => Promise<string>; // return response as text
}
const { ZipArchive } = NativeModules;
@@ -180,7 +180,7 @@ function RestoreBackup({
}}
>
<Text style={{ color: theme.primary }}>
{item.name.replace(/\.backup$/, ' ')}
{item.name?.replace(/\.backup$/, ' ')}
</Text>
<Text style={{ color: theme.secondary, fontSize: 12 }}>
{'(' + dayjs(item.createdTime).format('LL') + ')'}
@@ -219,7 +219,7 @@ export default function GoogleDriveModal({
const [user, setUser] = useState<User | null | undefined>(null);
useEffect(() => {
GoogleSignin.configure({
scopes: ['https://www.googleapis.com/auth/drive'],
scopes: ['https://www.googleapis.com/auth/drive.file'],
});
GoogleSignin.isSignedIn()
.then(isSignedIn => {
@@ -303,10 +303,12 @@ export default function GoogleDriveModal({
}
}}
>
<Image
source={{ uri: user?.user.photo || '' }}
style={styles.avatar}
/>
{user && (
<Image
source={{ uri: user?.user.photo || '' }}
style={styles.avatar}
/>
)}
</TouchableOpacity>
</View>
{renderModal()}
@@ -78,7 +78,7 @@ function RestoreBackup({
}) {
const [backupList, setBackupList] = useState<string[]>([]);
useEffect(() => {
list(host, []).then(items =>
list(host).then(items =>
setBackupList(items.filter(item => item.endsWith('.backup'))),
);
}, []);
-113
View File
@@ -1,113 +0,0 @@
import {
BackupPackage,
BackupTask,
BackupDataFileName,
TaskType,
} from '../types';
import { appVersion } from '@utils/versionUtils';
import { getAllNovels } from '@database/queries/NovelQueries';
import { AppDownloadFolder } from '@utils/constants/download';
import { getChapters } from '@database/queries/ChapterQueries';
import {
getCategoriesFromDb,
getAllNovelCategories,
} from '@database/queries/CategoryQueries';
import { walkDir, backupMMKVData } from '../utils';
export const versionTask = async (parentId: string): Promise<BackupTask> => {
const backupPackage: BackupPackage = {
folderTree: [parentId],
content: JSON.stringify({
version: appVersion,
}),
name: BackupDataFileName.VERSION,
mimeType: 'application/json',
};
return {
taskType: TaskType.VERSION,
subtasks: [async () => backupPackage],
};
};
export const novelTask = async (parentId: string): Promise<BackupTask> => {
return getAllNovels().then(novels => {
const subtasks = novels.map(novel => {
return (): Promise<BackupPackage> =>
getChapters(novel.id).then(chapters => {
return {
folderTree: [parentId],
name: novel.id + '.json',
content: JSON.stringify({
chapters: chapters,
...novel,
}),
mimeType: 'application/json',
};
});
});
return {
taskType: TaskType.NOVEL_AND_CHAPTERS,
subtasks: subtasks,
};
});
};
export const categoryTask = (parentId: string): Promise<BackupTask> => {
return getCategoriesFromDb().then(categories => {
const task = async (): Promise<BackupPackage> => {
return getAllNovelCategories().then(novelCategories => {
return {
folderTree: [parentId],
name: BackupDataFileName.CATEGORY,
mimeType: 'application/json',
content: JSON.stringify(
categories.map(category => {
return {
...category,
novelIds: novelCategories
.filter(nc => nc.categoryId === category.id)
.map(nc => nc.novelId),
};
}),
),
};
});
};
return {
taskType: TaskType.CATEGORY,
subtasks: [task],
};
});
};
export const downloadTask = (parentId: string): Promise<BackupTask> => {
return walkDir(AppDownloadFolder).then(items => {
return {
taskType: TaskType.DOWNLOAD,
subtasks: items.map(item => {
return async (): Promise<BackupPackage> => {
return {
folderTree: [parentId],
name: item.path.replace(AppDownloadFolder + '/', ''),
content: item.uri,
mimeType: item.mimeType,
};
};
}),
};
});
};
export const settingTask = async (parentId: string): Promise<BackupTask> => {
const data = backupMMKVData();
const backupPackage: BackupPackage = {
folderTree: [parentId],
name: BackupDataFileName.SETTING,
mimeType: 'application/json',
content: JSON.stringify(data),
};
return {
taskType: TaskType.SETTING,
subtasks: [async () => backupPackage],
};
};
+122 -100
View File
@@ -3,22 +3,13 @@ import { sleep } from '@utils/sleep';
import BackgroundService from 'react-native-background-actions';
import * as Notifications from 'expo-notifications';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { createFile, exists, makeDir } from '@api/drive';
import {
categoryTask,
downloadTask,
novelTask,
settingTask,
versionTask,
} from './backupTasks';
import {
restoreCategory,
restoreNovel,
restoreSetting,
retoreDownload,
} from './restoreTasks';
import { exists } from '@api/drive';
import { BACKGROUND_ACTION, BackgoundAction } from '@services/constants';
import { getString } from '@strings/translations';
import { CACHE_DIR_PATH, prepareBackupData, restoreData } from '../utils';
import { download, updateMetadata, uploadMedia } from '@api/drive/request';
import { AppDownloadFolder } from '@utils/constants/download';
import { ZipBackupName } from '../types';
interface TaskData {
delay: number;
@@ -32,55 +23,69 @@ const driveBackupAction = async (taskData?: TaskData) => {
throw new Error('No data provided');
}
const { delay, backupFolder } = taskData;
await sleep(delay);
const dataFolder = await makeDir('Data', backupFolder.id);
const downloadFolder = await makeDir('Download', backupFolder.id);
const novelFolder = await makeDir('NovelAndChapters', dataFolder.id);
const taskList = [
versionTask(dataFolder.id),
novelTask(novelFolder.id),
categoryTask(dataFolder.id),
downloadTask(downloadFolder.id),
settingTask(dataFolder.id),
];
for (let i = 0; i < taskList.length; i++) {
const { taskType, subtasks } = await taskList[i];
for (let j = 0; j < subtasks.length; j++) {
await BackgroundService.updateNotification({
taskDesc: `${getString('common.backup')} ${taskType} (${j}/${
subtasks.length
})`,
progressBar: {
max: subtasks.length,
value: j,
},
})
.then(() => subtasks[j]())
.then(backupPackage =>
createFile(
backupPackage.name,
backupPackage.mimeType,
backupPackage.content,
backupPackage.folderTree[0],
),
)
.then(() => sleep(delay))
.catch(error => {
throw error;
});
}
}
await Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.drive.backup'),
body: getString('common.done'),
await BackgroundService.updateNotification({
taskDesc: 'Preparing Data',
progressBar: {
indeterminate: true,
value: 0,
max: 3,
},
trigger: null,
});
})
.then(() => prepareBackupData(CACHE_DIR_PATH))
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Uploading Data',
progressBar: {
indeterminate: true,
value: 1,
max: 3,
},
}),
)
.then(() => sleep(delay))
.then(() => uploadMedia(CACHE_DIR_PATH))
.then(file => {
return updateMetadata(
file.id,
{
name: ZipBackupName.DATA,
mimeType: 'application/zip',
parents: [backupFolder.id],
},
file.parents[0],
);
})
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Uploading Downloaded files',
progressBar: {
indeterminate: true,
value: 2,
max: 3,
},
}),
)
.then(() => uploadMedia(AppDownloadFolder))
.then(file => {
return updateMetadata(
file.id,
{
name: ZipBackupName.DOWNLOAD,
mimeType: 'application/zip',
parents: [backupFolder.id],
},
file.parents[0],
);
})
.then(() => {
return Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.drive.backup'),
body: getString('common.done'),
},
trigger: null,
});
});
} catch (error: any) {
if (BackgroundService.isRunning()) {
await Notifications.scheduleNotificationAsync({
@@ -127,47 +132,64 @@ const driveRestoreAction = async (taskData?: TaskData) => {
const { delay, backupFolder } = taskData;
await sleep(delay);
const dataFolder = await exists('Data', true, backupFolder.id);
const downloadFolder = await exists('Download', true, backupFolder.id);
const novelFolder = await exists('NovelAndChapters', true, dataFolder?.id);
if (!dataFolder || !downloadFolder || !novelFolder) {
const zipDataFile = await exists(
ZipBackupName.DATA,
false,
backupFolder.id,
);
const zipDownloadFile = await exists(
ZipBackupName.DOWNLOAD,
false,
backupFolder.id,
);
if (!zipDataFile || !zipDownloadFile) {
throw new Error('Invalid backup folder');
}
const taskList = [
restoreNovel(novelFolder.id),
restoreCategory(dataFolder.id),
retoreDownload(downloadFolder.id),
restoreSetting(dataFolder.id),
];
for (let i = 0; i < taskList.length; i++) {
const { taskType, subtasks } = await taskList[i]();
for (let j = 0; j < subtasks.length; j++) {
await BackgroundService.updateNotification({
taskDesc: `${getString('common.restore')} ${taskType} (${j}/${
subtasks.length
})`,
progressBar: {
max: subtasks.length,
value: j,
},
})
.then(() => subtasks[j]())
.then(() => sleep(delay))
.catch(error => {
throw error;
});
}
}
await Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.drive.restore'),
body: getString('common.done'),
await BackgroundService.updateNotification({
taskDesc: 'Downloading Data',
progressBar: {
indeterminate: true,
value: 0,
max: 3,
},
trigger: null,
});
})
.then(() => download(zipDataFile, CACHE_DIR_PATH))
.then(() => sleep(delay))
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Restoring Data',
progressBar: {
indeterminate: true,
value: 1,
max: 3,
},
}),
)
.then(() => restoreData(CACHE_DIR_PATH))
.then(() => sleep(delay))
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Downloading Downloaded files',
progressBar: {
indeterminate: true,
value: 2,
max: 3,
},
}),
)
.then(() => download(zipDownloadFile, AppDownloadFolder))
.then(() => {
return Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.drive.restore'),
body: getString('common.done'),
},
trigger: null,
});
})
.catch(error => {
throw error;
});
} catch (error: any) {
if (BackgroundService.isRunning()) {
await Notifications.scheduleNotificationAsync({
-83
View File
@@ -1,83 +0,0 @@
import { exists, readDir, readFile } from '@api/drive';
import { BackupDataFileName, RestoreTask, TaskType } from '../types';
import { BackupCategory, BackupNovel } from '@database/types';
import { _restoreNovelAndChapters } from '@database/queries/NovelQueries';
import { _restoreCategory } from '@database/queries/CategoryQueries';
import { restoreMMKVData } from '../utils';
export const restoreNovel = (
parentId: string,
): (() => Promise<RestoreTask>) => {
return () =>
readDir(parentId).then(files => {
const subtasks = files.map(file => {
return (): Promise<void> => {
return readFile(file, 'json').then((novel: BackupNovel) =>
_restoreNovelAndChapters(novel),
);
};
});
return {
taskType: TaskType.NOVEL_AND_CHAPTERS,
subtasks: subtasks,
};
});
};
export const restoreCategory = (
parentId: string,
): (() => Promise<RestoreTask>) => {
return () =>
exists(BackupDataFileName.CATEGORY, false, parentId).then(file => {
if (file) {
return readFile(file, 'json').then((categories: BackupCategory[]) => {
const subtasks = categories.map(category => {
return async () => _restoreCategory(category);
});
return {
taskType: TaskType.CATEGORY,
subtasks: subtasks,
} as RestoreTask;
});
}
return {
taskType: TaskType.CATEGORY,
subtasks: [],
};
});
};
export const retoreDownload = (
parentId: string,
): (() => Promise<RestoreTask>) => {
return () =>
readDir(parentId).then(files => {
return {
taskType: TaskType.DOWNLOAD,
subtasks: files.map(file => () => readFile(file, 'media')),
};
});
};
export const restoreSetting = (
parentId: string,
): (() => Promise<RestoreTask>) => {
return () =>
exists(BackupDataFileName.SETTING, false, parentId).then(file => {
if (file) {
return readFile(file, 'json').then(data => {
const subtask = async () => {
restoreMMKVData(data);
};
return {
taskType: TaskType.SETTING,
subtasks: [subtask],
} as RestoreTask;
});
}
return {
taskType: TaskType.SETTING,
subtasks: [],
};
});
};
-117
View File
@@ -1,117 +0,0 @@
import { appVersion } from '@utils/versionUtils';
import { AppDownloadFolder } from '@utils/constants/download';
import { walkDir, backupMMKVData } from '../utils';
import {
BackupPackage,
BackupTask,
BackupDataFileName,
TaskType,
} from '../types';
import { getAllNovels } from '@database/queries/NovelQueries';
import { getChapters } from '@database/queries/ChapterQueries';
import {
getAllNovelCategories,
getCategoriesFromDb,
} from '@database/queries/CategoryQueries';
export const versionTask = async (
folderTree: string[],
): Promise<BackupTask> => {
const backupPackage: BackupPackage = {
folderTree,
content: JSON.stringify({
version: appVersion,
}),
name: BackupDataFileName.VERSION,
mimeType: 'application/json',
};
return {
taskType: TaskType.VERSION,
subtasks: [async () => backupPackage],
};
};
export const novelTask = async (folderTree: string[]): Promise<BackupTask> => {
return getAllNovels().then(novels => {
const subtasks = novels.map(novel => {
return (): Promise<BackupPackage> =>
getChapters(novel.id).then(chapters => {
return {
folderTree: folderTree,
name: novel.id + '.json',
content: JSON.stringify({
chapters: chapters,
...novel,
}),
mimeType: 'application/json',
};
});
});
return {
taskType: TaskType.NOVEL_AND_CHAPTERS,
subtasks: subtasks,
};
});
};
export const categoryTask = (folderTree: string[]): Promise<BackupTask> => {
return getCategoriesFromDb().then(categories => {
const task = async (): Promise<BackupPackage> => {
return getAllNovelCategories().then(novelCategories => {
return {
folderTree,
name: BackupDataFileName.CATEGORY,
mimeType: 'application/json',
content: JSON.stringify(
categories.map(category => {
return {
...category,
novelIds: novelCategories
.filter(nc => nc.categoryId === category.id)
.map(nc => nc.novelId),
};
}),
),
};
});
};
return {
taskType: TaskType.CATEGORY,
subtasks: [task],
};
});
};
export const downloadTask = (folderTree: string[]): Promise<BackupTask> => {
return walkDir(AppDownloadFolder).then(items => {
return {
taskType: TaskType.DOWNLOAD,
subtasks: items.map(item => {
return async (): Promise<BackupPackage> => {
return {
folderTree,
name: item.path.replace(AppDownloadFolder + '/', ''),
content: item.uri,
mimeType: item.mimeType,
};
};
}),
};
});
};
export const settingTask = async (
folderTree: string[],
): Promise<BackupTask> => {
const data = backupMMKVData();
const backupPackage: BackupPackage = {
folderTree,
name: BackupDataFileName.SETTING,
mimeType: 'application/json',
content: JSON.stringify(data),
};
return {
taskType: TaskType.SETTING,
subtasks: [async () => backupPackage],
};
};
+100 -97
View File
@@ -2,23 +2,12 @@ import { MMKVStorage } from '@utils/mmkv/mmkv';
import { sleep } from '@utils/sleep';
import BackgroundService from 'react-native-background-actions';
import * as Notifications from 'expo-notifications';
import { upload } from '@api/remote';
import {
categoryTask,
downloadTask,
novelTask,
settingTask,
versionTask,
} from './backupTasks';
import {
restoreCategory,
restoreNovel,
restoreSetting,
retoreDownload,
} from './restoreTasks';
import { download, upload } from '@api/remote';
import { BACKGROUND_ACTION, BackgoundAction } from '@services/constants';
import { getString } from '@strings/translations';
import { CACHE_DIR_PATH, prepareBackupData, restoreData } from '../utils';
import { AppDownloadFolder } from '@utils/constants/download';
import { ZipBackupName } from '../types';
interface TaskData {
delay: number;
@@ -33,47 +22,55 @@ const remoteBackupAction = async (taskData?: TaskData) => {
throw new Error('No data provided');
}
const { delay, backupFolder, host } = taskData;
await sleep(delay);
const dataFolder = [backupFolder, 'Data'];
const downloadFolder = [backupFolder, 'Download'];
const novelFolder = [backupFolder, 'Data', 'NovelAndChapters'];
const taskList = [
versionTask(dataFolder),
novelTask(novelFolder),
categoryTask(dataFolder),
downloadTask(downloadFolder),
settingTask(dataFolder),
];
for (let i = 0; i < taskList.length; i++) {
const { taskType, subtasks } = await taskList[i];
for (let j = 0; j < subtasks.length; j++) {
await BackgroundService.updateNotification({
taskDesc: `${getString('common.backup')} ${taskType} (${j}/${
subtasks.length
})`,
progressBar: {
max: subtasks.length,
value: j,
},
})
.then(() => subtasks[j]())
.then(backupPackage => upload(host, backupPackage))
.catch(error => {
throw error;
});
}
}
await Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.remote.backup'),
body: getString('common.done'),
await BackgroundService.updateNotification({
taskDesc: 'Preparing Data',
progressBar: {
indeterminate: true,
value: 0,
max: 3,
},
trigger: null,
});
})
.then(() => prepareBackupData(CACHE_DIR_PATH))
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Uploading Data',
progressBar: {
indeterminate: true,
value: 1,
max: 3,
},
}),
)
.then(() => sleep(delay))
.then(() =>
upload(host, backupFolder, ZipBackupName.DATA, CACHE_DIR_PATH),
)
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Uploading Downloaded files',
progressBar: {
indeterminate: true,
value: 2,
max: 3,
},
}),
)
.then(() => sleep(delay))
.then(() =>
upload(host, backupFolder, ZipBackupName.DOWNLOAD, AppDownloadFolder),
)
.then(() => {
return Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.remote.backup'),
body: getString('common.done'),
},
trigger: null,
});
})
.catch(error => {
throw error;
});
} catch (error: any) {
if (BackgroundService.isRunning()) {
await Notifications.scheduleNotificationAsync({
@@ -118,49 +115,55 @@ const remoteRestoreAction = async (taskData?: TaskData) => {
throw new Error('No data provided');
}
const { delay, backupFolder, host } = taskData;
await sleep(delay);
const dataFolder = [backupFolder, 'Data'];
const downloadFolder = [backupFolder, 'Download'];
const novelFolder = [backupFolder, 'Data', 'NovelAndChapters'];
if (!dataFolder || !downloadFolder || !novelFolder) {
throw new Error('Invalid backup folder');
}
const taskList = [
restoreNovel(host, novelFolder),
restoreCategory(host, dataFolder),
retoreDownload(host, downloadFolder),
restoreSetting(host, dataFolder),
];
for (let i = 0; i < taskList.length; i++) {
const { taskType, subtasks } = await taskList[i]();
for (let j = 0; j < subtasks.length; j++) {
await BackgroundService.updateNotification({
taskDesc: `${getString('common.restore')} ${taskType} (${j}/${
subtasks.length
})`,
progressBar: {
max: subtasks.length,
value: j,
},
})
.then(() => subtasks[j]())
.catch(error => {
throw error;
});
}
}
await Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.remote.restore'),
body: getString('common.done'),
await BackgroundService.updateNotification({
taskDesc: 'Downloading Data',
progressBar: {
indeterminate: true,
value: 0,
max: 3,
},
trigger: null,
});
})
.then(() =>
download(host, backupFolder, ZipBackupName.DATA, CACHE_DIR_PATH),
)
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Restoring Data',
progressBar: {
indeterminate: true,
value: 1,
max: 3,
},
}),
)
.then(() => sleep(delay))
.then(() => restoreData(CACHE_DIR_PATH))
.then(() =>
BackgroundService.updateNotification({
taskDesc: 'Downloading Downloaded files',
progressBar: {
indeterminate: true,
value: 2,
max: 3,
},
}),
)
.then(() => sleep(delay))
.then(() =>
download(host, backupFolder, ZipBackupName.DOWNLOAD, AppDownloadFolder),
)
.then(() =>
Notifications.scheduleNotificationAsync({
content: {
title: getString('backupScreen.remote.restore'),
body: getString('common.done'),
},
trigger: null,
}),
)
.catch(error => {
throw error;
});
} catch (error: any) {
if (BackgroundService.isRunning()) {
await Notifications.scheduleNotificationAsync({
@@ -1,91 +0,0 @@
import { BackupDataFileName, RestoreTask, TaskType } from '../types';
import { BackupCategory, BackupNovel } from '@database/types';
import { _restoreNovelAndChapters } from '@database/queries/NovelQueries';
import { _restoreCategory } from '@database/queries/CategoryQueries';
import { download, exists, getJson, list } from '@api/remote';
import { restoreMMKVData } from '../utils';
export const restoreNovel = (
host: string,
folderTree: string[],
): (() => Promise<RestoreTask>) => {
return () =>
list(host, folderTree).then(files => {
const subtasks = files.map(file => {
return (): Promise<void> => {
return getJson(host, folderTree, file).then((novel: BackupNovel) =>
_restoreNovelAndChapters(novel),
);
};
});
return {
taskType: TaskType.NOVEL_AND_CHAPTERS,
subtasks: subtasks,
};
});
};
export const restoreCategory = (
host: string,
folderTree: string[],
): (() => Promise<RestoreTask>) => {
return () =>
exists(host, folderTree, BackupDataFileName.CATEGORY).then(existed => {
if (existed) {
return getJson(host, folderTree, BackupDataFileName.CATEGORY).then(
(categories: BackupCategory[]) => {
const subtasks = categories.map(category => {
return async () => _restoreCategory(category);
});
return {
taskType: TaskType.CATEGORY,
subtasks: subtasks,
} as RestoreTask;
},
);
}
return {
taskType: TaskType.CATEGORY,
subtasks: [],
};
});
};
export const retoreDownload = (
host: string,
folderTree: string[],
): (() => Promise<RestoreTask>) => {
return () =>
list(host, folderTree).then(files => {
return {
taskType: TaskType.DOWNLOAD,
subtasks: files.map(file => () => download(host, folderTree, file)),
};
});
};
export const restoreSetting = (
host: string,
folderTree: string[],
): (() => Promise<RestoreTask>) => {
return () =>
exists(host, folderTree, BackupDataFileName.SETTING).then(existed => {
if (existed) {
return getJson(host, folderTree, BackupDataFileName.SETTING).then(
data => {
const subtask = async () => {
restoreMMKVData(data);
};
return {
taskType: TaskType.SETTING,
subtasks: [subtask],
} as RestoreTask;
},
);
}
return {
taskType: TaskType.SETTING,
subtasks: [],
};
});
};
+5 -32
View File
@@ -1,38 +1,11 @@
export enum BackupFolderName {
DATA = 'Data',
DOWNLOAD = 'Download',
NOVEL_AND_CHAPTERS = 'NovelAndChapters',
export enum ZipBackupName {
DATA = 'data.zip',
DOWNLOAD = 'download.zip',
}
export enum BackupDataFileName {
export enum BackupEntryName {
VERSION = 'Version.json',
CATEGORY = 'Category.json',
SETTING = 'Setting.json',
THEME = 'Theme.json',
}
export enum TaskType {
VERSION = 'Version',
CATEGORY = 'Category',
NOVEL_AND_CHAPTERS = 'Novel and Chapters',
DOWNLOAD = 'Download',
SETTING = 'Setting',
THEME = 'Theme',
}
export interface BackupPackage {
folderTree: string[]; // for Drive, it must be [parentId]
content: string; // file text content or file uri
name: string;
mimeType: string;
}
export interface RestoreTask {
taskType: TaskType;
subtasks: Array<() => Promise<void>>;
}
export interface BackupTask {
taskType: TaskType; // for notification
subtasks: Array<() => Promise<BackupPackage>>;
NOVEL_AND_CHAPTERS = 'NovelAndChapters',
}
+105 -216
View File
@@ -4,223 +4,23 @@ import { TRACKER } from '@hooks/persisted/useTracker';
import { LAST_UPDATE_TIME } from '@hooks/persisted/useUpdates';
import { BACKGROUND_ACTION } from '@services/constants';
import { MMKVStorage } from '@utils/mmkv/mmkv';
import { appVersion } from '@utils/versionUtils';
import {
_restoreNovelAndChapters,
getAllNovels,
} from '@database/queries/NovelQueries';
import { getChapters } from '@database/queries/ChapterQueries';
import {
_restoreCategory,
getAllNovelCategories,
getCategoriesFromDb,
} from '@database/queries/CategoryQueries';
import { BackupCategory } from '@database/types';
import { BackupEntryName } from './types';
const mimeTypes = {
// File Extension MIME Type
'abs': 'audio/x-mpeg',
'ai': 'application/postscript',
'aif': 'audio/x-aiff',
'aifc': 'audio/x-aiff',
'aiff': 'audio/x-aiff',
'aim': 'application/x-aim',
'art': 'image/x-jg',
'asf': 'video/x-ms-asf',
'asx': 'video/x-ms-asf',
'au': 'audio/basic',
'avi': 'video/x-msvideo',
'avx': 'video/x-rad-screenplay',
'bcpio': 'application/x-bcpio',
'bin': 'application/octet-stream',
'bmp': 'image/bmp',
'body': 'text/html',
'cdf': 'application/x-cdf',
'cer': 'application/pkix-cert',
'class': 'application/java',
'cpio': 'application/x-cpio',
'csh': 'application/x-csh',
'css': 'text/css',
'dib': 'image/bmp',
'doc': 'application/msword',
'dtd': 'application/xml-dtd',
'dv': 'video/x-dv',
'dvi': 'application/x-dvi',
'eot': 'application/vnd.ms-fontobject',
'eps': 'application/postscript',
'etx': 'text/x-setext',
'exe': 'application/octet-stream',
'gif': 'image/gif',
'gtar': 'application/x-gtar',
'gz': 'application/x-gzip',
'hdf': 'application/x-hdf',
'hqx': 'application/mac-binhex40',
'htc': 'text/x-component',
'htm': 'text/html',
'html': 'text/html',
'ief': 'image/ief',
'jad': 'text/vnd.sun.j2me.app-descriptor',
'jar': 'application/java-archive',
'java': 'text/x-java-source',
'jnlp': 'application/x-java-jnlp-file',
'jpe': 'image/jpeg',
'jpeg': 'image/jpeg',
'jpg': 'image/jpeg',
'js': 'application/javascript',
'jsf': 'text/plain',
'json': 'application/json',
'jspf': 'text/plain',
'kar': 'audio/midi',
'latex': 'application/x-latex',
'm3u': 'audio/x-mpegurl',
'mac': 'image/x-macpaint',
'man': 'text/troff',
'mathml': 'application/mathml+xml',
'me': 'text/troff',
'mid': 'audio/midi',
'midi': 'audio/midi',
'mif': 'application/x-mif',
'mov': 'video/quicktime',
'movie': 'video/x-sgi-movie',
'mp1': 'audio/mpeg',
'mp2': 'audio/mpeg',
'mp3': 'audio/mpeg',
'mp4': 'video/mp4',
'mpa': 'audio/mpeg',
'mpe': 'video/mpeg',
'mpeg': 'video/mpeg',
'mpega': 'audio/x-mpeg',
'mpg': 'video/mpeg',
'mpv2': 'video/mpeg2',
'ms': 'application/x-wais-source',
'nc': 'application/x-netcdf',
'oda': 'application/oda',
'odb': 'application/vnd.oasis.opendocument.database',
'odc': 'application/vnd.oasis.opendocument.chart',
'odf': 'application/vnd.oasis.opendocument.formula',
'odg': 'application/vnd.oasis.opendocument.graphics',
'odi': 'application/vnd.oasis.opendocument.image',
'odm': 'application/vnd.oasis.opendocument.text-master',
'odp': 'application/vnd.oasis.opendocument.presentation',
'ods': 'application/vnd.oasis.opendocument.spreadsheet',
'odt': 'application/vnd.oasis.opendocument.text',
'otg': 'application/vnd.oasis.opendocument.graphics-template',
'oth': 'application/vnd.oasis.opendocument.text-web',
'otp': 'application/vnd.oasis.opendocument.presentation-template',
'ots': 'application/vnd.oasis.opendocument.spreadsheet-template',
'ott': 'application/vnd.oasis.opendocument.text-template',
'ogx': 'application/ogg',
'ogv': 'video/ogg',
'oga': 'audio/ogg',
'ogg': 'audio/ogg',
'otf': 'application/x-font-opentype',
'spx': 'audio/ogg',
'flac': 'audio/flac',
'anx': 'application/annodex',
'axa': 'audio/annodex',
'axv': 'video/annodex',
'xspf': 'application/xspf+xml',
'pbm': 'image/x-portable-bitmap',
'pct': 'image/pict',
'pdf': 'application/pdf',
'pgm': 'image/x-portable-graymap',
'pic': 'image/pict',
'pict': 'image/pict',
'pls': 'audio/x-scpls',
'png': 'image/png',
'pnm': 'image/x-portable-anymap',
'pnt': 'image/x-macpaint',
'ppm': 'image/x-portable-pixmap',
'ppt': 'application/vnd.ms-powerpoint',
'pps': 'application/vnd.ms-powerpoint',
'ps': 'application/postscript',
'psd': 'image/vnd.adobe.photoshop',
'qt': 'video/quicktime',
'qti': 'image/x-quicktime',
'qtif': 'image/x-quicktime',
'ras': 'image/x-cmu-raster',
'rdf': 'application/rdf+xml',
'rgb': 'image/x-rgb',
'rm': 'application/vnd.rn-realmedia',
'roff': 'text/troff',
'rtf': 'application/rtf',
'rtx': 'text/richtext',
'sfnt': 'application/font-sfnt',
'sh': 'application/x-sh',
'shar': 'application/x-shar',
'sit': 'application/x-stuffit',
'snd': 'audio/basic',
'src': 'application/x-wais-source',
'sv4cpio': 'application/x-sv4cpio',
'sv4crc': 'application/x-sv4crc',
'svg': 'image/svg+xml',
'svgz': 'image/svg+xml',
'swf': 'application/x-shockwave-flash',
't': 'text/troff',
'tar': 'application/x-tar',
'tcl': 'application/x-tcl',
'tex': 'application/x-tex',
'texi': 'application/x-texinfo',
'texinfo': 'application/x-texinfo',
'tif': 'image/tiff',
'tiff': 'image/tiff',
'tr': 'text/troff',
'tsv': 'text/tab-separated-values',
'ttf': 'application/x-font-ttf',
'txt': 'text/plain',
'ulw': 'audio/basic',
'ustar': 'application/x-ustar',
'vxml': 'application/voicexml+xml',
'xbm': 'image/x-xbitmap',
'xht': 'application/xhtml+xml',
'xhtml': 'application/xhtml+xml',
'xls': 'application/vnd.ms-excel',
'xml': 'application/xml',
'xpm': 'image/x-xpixmap',
'xsl': 'application/xml',
'xslt': 'application/xslt+xml',
'xul': 'application/vnd.mozilla.xul+xml',
'xwd': 'image/x-xwindowdump',
'vsd': 'application/vnd.visio',
'wav': 'audio/x-wav',
'wbmp': 'image/vnd.wap.wbmp',
'wml': 'text/vnd.wap.wml',
'wmlc': 'application/vnd.wap.wmlc',
'wmls': 'text/vnd.wap.wmlsc',
'wmlscriptc': 'application/vnd.wap.wmlscriptc',
'wmv': 'video/x-ms-wmv',
'woff': 'application/font-woff',
'woff2': 'application/font-woff2',
'wrl': 'model/vrml',
'wspolicy': 'application/wspolicy+xml',
'z': 'application/x-compress',
'zip': 'application/zip',
};
export const CACHE_DIR_PATH = RNFS.ExternalCachesDirectoryPath + '/BackupData';
interface PathObject {
name: string;
uri: string;
path: string;
mimeType: string;
}
const getMimeType = (path: string) => {
const extension = path.split('.').slice(-1)[0];
for (let [k, v] of Object.entries(mimeTypes)) {
if (k === extension) {
return v;
}
}
return 'text/plain';
};
export const walkDir = async (path: string) => {
let paths: PathObject[] = [];
const items = await RNFS.readDir(path).catch(() => []);
for (let item of items) {
if (item.isFile()) {
paths.push({
name: item.name,
uri: 'file:///' + item.path,
path: item.path,
mimeType: getMimeType(item.path),
});
} else {
paths = paths.concat(await walkDir(item.path));
}
}
return paths;
};
export const backupMMKVData = () => {
const backupMMKVData = () => {
const excludeKeys = [
BACKGROUND_ACTION,
TRACKER,
@@ -244,8 +44,97 @@ export const backupMMKVData = () => {
return data;
};
export const restoreMMKVData = (data: any) => {
const restoreMMKVData = (data: any) => {
for (let key in data) {
MMKVStorage.set(key, data[key]);
}
};
export const prepareBackupData = async (cacheDirPath: string) => {
const novelDirPath = cacheDirPath + '/' + BackupEntryName.NOVEL_AND_CHAPTERS;
if (await RNFS.exists(novelDirPath)) {
await RNFS.unlink(novelDirPath);
}
await RNFS.mkdir(novelDirPath); // this also creates cacheDirPath
// version
await RNFS.writeFile(
cacheDirPath + '/' + BackupEntryName.VERSION,
JSON.stringify({ version: appVersion }),
);
// novels
await getAllNovels().then(async novels => {
for (const novel of novels) {
const chapters = await getChapters(novel.id);
await RNFS.writeFile(
novelDirPath + '/' + novel.id + '.json',
JSON.stringify({
chapters: chapters,
...novel,
}),
);
}
});
// categories
await getCategoriesFromDb().then(categories => {
return getAllNovelCategories().then(async novelCategories => {
await RNFS.writeFile(
cacheDirPath + '/' + BackupEntryName.CATEGORY,
JSON.stringify(
categories.map(category => {
return {
...category,
novelIds: novelCategories
.filter(nc => nc.categoryId === category.id)
.map(nc => nc.novelId),
};
}),
),
);
});
});
// settings
await RNFS.writeFile(
cacheDirPath + '/' + BackupEntryName.SETTING,
JSON.stringify(backupMMKVData()),
);
};
export const restoreData = async (cacheDirPath: string) => {
const novelDirPath = cacheDirPath + '/' + BackupEntryName.NOVEL_AND_CHAPTERS;
// version
// nothing to do
// novels
await RNFS.readDir(novelDirPath).then(async items => {
for (const item of items) {
if (item.isFile()) {
await RNFS.readFile(item.path).then(content =>
_restoreNovelAndChapters(JSON.parse(content)),
);
}
}
});
// categories
await RNFS.readFile(cacheDirPath + '/' + BackupEntryName.CATEGORY).then(
async content => {
const categories: BackupCategory[] = JSON.parse(content);
for (const category of categories) {
await _restoreCategory(category);
}
},
);
// settings
await RNFS.readFile(cacheDirPath + '/' + BackupEntryName.SETTING).then(
content => {
restoreMMKVData(JSON.parse(content));
},
);
};