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:
@@ -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,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
@@ -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',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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
@@ -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, {});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'))),
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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],
|
||||
};
|
||||
};
|
||||
@@ -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: [],
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user