diff --git a/android/app/src/main/java/com/rajarsheechatterjee/ZipArchive/ZipArchive.java b/android/app/src/main/java/com/rajarsheechatterjee/ZipArchive/ZipArchive.java index 186f44d8..d03715c9 100644 --- a/android/app/src/main/java/com/rajarsheechatterjee/ZipArchive/ZipArchive.java +++ b/android/app/src/main/java/com/rajarsheechatterjee/ZipArchive/ZipArchive.java @@ -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> it = headers.getEntryIterator(); + while (it.hasNext()){ + Map.Entry 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 walkDir(String path) { + ArrayList 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 childPaths = walkDir(new File(path, filename).toString()); + res.addAll(childPaths); + } + } + } + return res; + } + private void zipProcess(String sourceDirPath, ZipOutputStream zos) throws Exception{ + ArrayList 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> it = headers.getEntryIterator(); + while (it.hasNext()){ + Map.Entry 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(); + } } diff --git a/src/api/drive/index.ts b/src/api/drive/index.ts index 56c69125..febd12d9 100644 --- a/src/api/drive/index.ts +++ b/src/api/drive/index.ts @@ -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); - } -}; diff --git a/src/api/drive/request.ts b/src/api/drive/request.ts index 5b7a48c4..4daca630 100644 --- a/src/api/drive/request.ts +++ b/src/api/drive/request.ts @@ -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, + 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 => { + 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', }); }; diff --git a/src/api/drive/types.ts b/src/api/drive/types.ts index a6bfd19b..9d21fd7b 100644 --- a/src/api/drive/types.ts +++ b/src/api/drive/types.ts @@ -22,6 +22,8 @@ export interface DriveRequestParams { fields?: string; pageToken?: string; uploadType?: string; // only for upload + addParents?: string; + removeParents?: string; } export interface DriveCreateRequestData { diff --git a/src/api/remote/index.ts b/src/api/remote/index.ts index 559bceee..d497c937 100644 --- a/src/api/remote/index.ts +++ b/src/api/remote/index.ts @@ -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 => { - 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 => { + 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 => { - 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, {}); }; diff --git a/src/database/queries/CategoryQueries.ts b/src/database/queries/CategoryQueries.ts index 425bafbe..c0c8614f 100644 --- a/src/database/queries/CategoryQueries.ts +++ b/src/database/queries/CategoryQueries.ts @@ -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, diff --git a/src/native/ZipArchive.ts b/src/native/ZipArchive.ts index 9e4de621..0b2b78fd 100644 --- a/src/native/ZipArchive.ts +++ b/src/native/ZipArchive.ts @@ -1,7 +1,16 @@ -import { SourceNovel } from '@plugins/types'; import { NativeModules } from 'react-native'; interface ZipArchiveInterface { - unzip: (epubFilePath: string, epubDirPath: string) => Promise; + unzip: (sourceFilePath: string, distDirPath: string) => Promise; + remoteUnzip: ( + distDirPath: string, + url: string, + headers: Record, + ) => Promise; + remoteZip: ( + sourceDirPath: string, + url: string, + headers: Record, + ) => Promise; // return response as text } const { ZipArchive } = NativeModules; diff --git a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx index d0c9e218..beafa16e 100644 --- a/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx +++ b/src/screens/settings/SettingsBackupScreen/Components/GoogleDriveModal.tsx @@ -180,7 +180,7 @@ function RestoreBackup({ }} > - {item.name.replace(/\.backup$/, ' ')} + {item.name?.replace(/\.backup$/, ' ')} {'(' + dayjs(item.createdTime).format('LL') + ')'} @@ -219,7 +219,7 @@ export default function GoogleDriveModal({ const [user, setUser] = useState(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({ } }} > - + {user && ( + + )} {renderModal()} diff --git a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx index 96acd7f1..1d6247c8 100644 --- a/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx +++ b/src/screens/settings/SettingsBackupScreen/Components/SelfHostModal.tsx @@ -78,7 +78,7 @@ function RestoreBackup({ }) { const [backupList, setBackupList] = useState([]); useEffect(() => { - list(host, []).then(items => + list(host).then(items => setBackupList(items.filter(item => item.endsWith('.backup'))), ); }, []); diff --git a/src/services/backup/drive/backupTasks.ts b/src/services/backup/drive/backupTasks.ts deleted file mode 100644 index 180115d4..00000000 --- a/src/services/backup/drive/backupTasks.ts +++ /dev/null @@ -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 => { - 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 => { - return getAllNovels().then(novels => { - const subtasks = novels.map(novel => { - return (): Promise => - 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 => { - return getCategoriesFromDb().then(categories => { - const task = async (): Promise => { - 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 => { - return walkDir(AppDownloadFolder).then(items => { - return { - taskType: TaskType.DOWNLOAD, - subtasks: items.map(item => { - return async (): Promise => { - return { - folderTree: [parentId], - name: item.path.replace(AppDownloadFolder + '/', ''), - content: item.uri, - mimeType: item.mimeType, - }; - }; - }), - }; - }); -}; - -export const settingTask = async (parentId: string): Promise => { - 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], - }; -}; diff --git a/src/services/backup/drive/index.ts b/src/services/backup/drive/index.ts index c835c5ae..88d71a6a 100644 --- a/src/services/backup/drive/index.ts +++ b/src/services/backup/drive/index.ts @@ -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({ diff --git a/src/services/backup/drive/restoreTasks.ts b/src/services/backup/drive/restoreTasks.ts deleted file mode 100644 index 2d9d0b86..00000000 --- a/src/services/backup/drive/restoreTasks.ts +++ /dev/null @@ -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) => { - return () => - readDir(parentId).then(files => { - const subtasks = files.map(file => { - return (): Promise => { - return readFile(file, 'json').then((novel: BackupNovel) => - _restoreNovelAndChapters(novel), - ); - }; - }); - return { - taskType: TaskType.NOVEL_AND_CHAPTERS, - subtasks: subtasks, - }; - }); -}; - -export const restoreCategory = ( - parentId: string, -): (() => Promise) => { - 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) => { - return () => - readDir(parentId).then(files => { - return { - taskType: TaskType.DOWNLOAD, - subtasks: files.map(file => () => readFile(file, 'media')), - }; - }); -}; - -export const restoreSetting = ( - parentId: string, -): (() => Promise) => { - 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: [], - }; - }); -}; diff --git a/src/services/backup/remote/backupTasks.ts b/src/services/backup/remote/backupTasks.ts deleted file mode 100644 index 444e6909..00000000 --- a/src/services/backup/remote/backupTasks.ts +++ /dev/null @@ -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 => { - 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 => { - return getAllNovels().then(novels => { - const subtasks = novels.map(novel => { - return (): Promise => - 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 => { - return getCategoriesFromDb().then(categories => { - const task = async (): Promise => { - 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 => { - return walkDir(AppDownloadFolder).then(items => { - return { - taskType: TaskType.DOWNLOAD, - subtasks: items.map(item => { - return async (): Promise => { - return { - folderTree, - name: item.path.replace(AppDownloadFolder + '/', ''), - content: item.uri, - mimeType: item.mimeType, - }; - }; - }), - }; - }); -}; - -export const settingTask = async ( - folderTree: string[], -): Promise => { - const data = backupMMKVData(); - const backupPackage: BackupPackage = { - folderTree, - name: BackupDataFileName.SETTING, - mimeType: 'application/json', - content: JSON.stringify(data), - }; - return { - taskType: TaskType.SETTING, - subtasks: [async () => backupPackage], - }; -}; diff --git a/src/services/backup/remote/index.ts b/src/services/backup/remote/index.ts index a6d0a88c..271703f1 100644 --- a/src/services/backup/remote/index.ts +++ b/src/services/backup/remote/index.ts @@ -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({ diff --git a/src/services/backup/remote/restoreTasks.ts b/src/services/backup/remote/restoreTasks.ts deleted file mode 100644 index c7917f84..00000000 --- a/src/services/backup/remote/restoreTasks.ts +++ /dev/null @@ -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) => { - return () => - list(host, folderTree).then(files => { - const subtasks = files.map(file => { - return (): Promise => { - 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) => { - 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) => { - 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) => { - 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: [], - }; - }); -}; diff --git a/src/services/backup/types.ts b/src/services/backup/types.ts index 413684ff..fb119c61 100644 --- a/src/services/backup/types.ts +++ b/src/services/backup/types.ts @@ -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>; -} - -export interface BackupTask { - taskType: TaskType; // for notification - subtasks: Array<() => Promise>; + NOVEL_AND_CHAPTERS = 'NovelAndChapters', } diff --git a/src/services/backup/utils.ts b/src/services/backup/utils.ts index 4c14020e..fab996db 100644 --- a/src/services/backup/utils.ts +++ b/src/services/backup/utils.ts @@ -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)); + }, + ); +};