diff --git a/.eslintignore b/.eslintignore index 15a33742062..56f772040b4 100644 --- a/.eslintignore +++ b/.eslintignore @@ -531,9 +531,12 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebu packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportProfileButton.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js +packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.js +packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js +packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js diff --git a/.gitignore b/.gitignore index 7ecec5d2e9b..0b72bf9afe7 100644 --- a/.gitignore +++ b/.gitignore @@ -509,9 +509,12 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebu packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportProfileButton.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js +packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.js +packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js +packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js diff --git a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx index ea93d0b40d0..51abd881abb 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx @@ -18,7 +18,7 @@ import * as shared from '@joplin/lib/components/shared/config/config-shared'; import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import biometricAuthenticate from '../../biometrics/biometricAuthenticate'; import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles'; -import NoteExportButton, { exportButtonDescription, exportButtonTitle } from './NoteExportSection/NoteExportButton'; +import NoteExportButton, { exportButtonDescription, exportButtonDefaultTitle } from './NoteExportSection/NoteExportButton'; import SettingsButton from './SettingsButton'; import Clipboard from '@react-native-community/clipboard'; import { ReactElement, ReactNode } from 'react'; @@ -33,6 +33,7 @@ import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/Plug import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates'; import PluginUploadButton, { buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton'; import isInstallingPluginsAllowed from './plugins/utils/isPluginInstallingAllowed'; +import NoteImportButton, { importButtonDefaultTitle, importButtonDescription } from './NoteExportSection/NoteImportButton'; interface ConfigScreenState { settings: any; @@ -514,7 +515,11 @@ class ConfigScreenComponent extends BaseScreenComponent, - [exportButtonTitle(), exportButtonDescription()], + [exportButtonDefaultTitle(), exportButtonDescription()], + ); + addSettingComponent( + , + [importButtonDefaultTitle(), importButtonDescription()], ); addSettingComponent( , diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx index 29b99115833..54bea77dc8b 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx +++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx @@ -1,16 +1,15 @@ import * as React from 'react'; -import { Text, Alert, View } from 'react-native'; import { _ } from '@joplin/lib/locale'; import Logger from '@joplin/utils/Logger'; -import { ProgressBar } from 'react-native-paper'; -import { FunctionComponent, useCallback, useState } from 'react'; +import { FunctionComponent } from 'react'; import shim from '@joplin/lib/shim'; import { join } from 'path'; import Share from 'react-native-share'; -import exportAllFolders, { makeExportCacheDirectory } from './utils/exportAllFolders'; +import exportAllFolders from './utils/exportAllFolders'; import { ExportProgressState } from '@joplin/lib/services/interop/types'; import { ConfigScreenStyles } from '../configScreenStyles'; -import SettingsButton from '../SettingsButton'; +import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory'; +import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton'; const logger = Logger.create('NoteExportButton'); @@ -18,99 +17,68 @@ interface Props { styles: ConfigScreenStyles; } -enum ExportStatus { - NotStarted, - Exporting, - Exported, -} - -export const exportButtonTitle = () => _('Export all notes as JEX'); +export const exportButtonDefaultTitle = () => _('Export all notes as JEX'); export const exportButtonDescription = () => _('Share a copy of all notes in a file format that can be imported by Joplin on a computer.'); -const NoteExportButton: FunctionComponent = props => { - const [exportStatus, setExportStatus] = useState(ExportStatus.NotStarted); - const [exportProgress, setExportProgress] = useState(0); - const [warnings, setWarnings] = useState(''); - - const startExport = useCallback(async () => { - // Don't run multiple exports at the same time. - if (exportStatus === ExportStatus.Exporting) { - return; - } - - setExportStatus(ExportStatus.Exporting); - const exportTargetPath = join(await makeExportCacheDirectory(), 'jex-export.jex'); - logger.info(`Exporting all folders to path ${exportTargetPath}`); - - try { - // Initially, undetermined progress - setExportProgress(undefined); - - const status = await exportAllFolders(exportTargetPath, (status, progress) => { - if (progress !== null) { - setExportProgress(progress); - } else if (status === ExportProgressState.Closing || status === ExportProgressState.QueuingItems) { - // We don't have a numeric progress value and the closing/queuing state may take a while. - // Set a special progress value: - setExportProgress(undefined); - } - }); +const getTitle = (taskStatus: TaskStatus) => { + if (taskStatus === TaskStatus.NotStarted) { + return exportButtonDefaultTitle(); + } else if (taskStatus === TaskStatus.InProgress) { + return _('Exporting...'); + } else { + return _('Exported successfully!'); + } +}; - setExportStatus(ExportStatus.Exported); - setWarnings(status.warnings.join('\n')); +const runExportTask = async ( + onProgress: OnProgressCallback, + setAfterCompleteListener: SetAfterCompleteListenerCallback, +) => { + const exportTargetPath = join(await makeImportExportCacheDirectory(), 'jex-export.jex'); + logger.info(`Exporting all folders to path ${exportTargetPath}`); + setAfterCompleteListener(async (success: boolean) => { + if (success) { await Share.open({ type: 'application/jex', filename: 'export.jex', url: `file://${exportTargetPath}`, failOnCancel: false, }); - } catch (e) { - logger.error('Unable to export:', e); - - // Display a message to the user (e.g. in the case where the user is out of disk space). - Alert.alert(_('Error'), _('Unable to export or share data. Reason: %s', e.toString())); - setExportStatus(ExportStatus.NotStarted); - } finally { - await shim.fsDriver().remove(exportTargetPath); } - }, [exportStatus]); + await shim.fsDriver().remove(exportTargetPath); + }); + + // Initially, undetermined progress + onProgress(undefined); + + const status = await exportAllFolders(exportTargetPath, (status, progress) => { + if (progress !== null) { + onProgress(progress); + } else if (status === ExportProgressState.Closing || status === ExportProgressState.QueuingItems) { + // We don't have a numeric progress value and the closing/queuing state may take a while. + // Set a special progress value: + onProgress(undefined); + } + }); - if (exportStatus === ExportStatus.NotStarted || exportStatus === ExportStatus.Exporting) { - const progressComponent = ( - - ); + onProgress(1); - const startOrCancelExportButton = ( - - ); + logger.info('Export complete'); - return startOrCancelExportButton; - } else { - const warningComponent = ( - - {_('Warnings:\n%s', warnings)} - - ); + return { warnings: status.warnings, success: true }; +}; - const exportSummary = ( - - {_('Exported successfully!')} - {warnings.length > 0 ? warningComponent : null} - - ); - return exportSummary; - } +const NoteExportButton: FunctionComponent = props => { + return ( + + ); }; export default NoteExportButton; diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.tsx new file mode 100644 index 00000000000..f23b3c8763c --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { _ } from '@joplin/lib/locale'; +import Logger from '@joplin/utils/Logger'; +import { FunctionComponent } from 'react'; +import { join } from 'path'; +import { ConfigScreenStyles } from '../configScreenStyles'; +import InteropService from '@joplin/lib/services/interop/InteropService'; +import pickDocument from '../../../../utils/pickDocument'; +import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory'; +import shim from '@joplin/lib/shim'; +import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton'; + +const logger = Logger.create('NoteImportButton'); + +interface Props { + styles: ConfigScreenStyles; +} + +export const importButtonDefaultTitle = () => _('Import from JEX'); +export const importButtonDescription = () => _('Loads a notebook from a JEX file.'); + +const getTitle = (taskStatus: TaskStatus) => { + if (taskStatus === TaskStatus.NotStarted) { + return importButtonDefaultTitle(); + } else if (taskStatus === TaskStatus.InProgress) { + return _('Importing...'); + } else { + return _('Imported successfully!'); + } +}; + +const runImportTask = async ( + _onProgress: OnProgressCallback, + setAfterCompleteListener: SetAfterCompleteListenerCallback, +) => { + const importTargetPath = join(await makeImportExportCacheDirectory(), 'to-import.jex'); + logger.info('Importing...'); + + setAfterCompleteListener(async (_success: boolean) => { + await shim.fsDriver().remove(importTargetPath); + }); + + const importFiles = await pickDocument(false); + if (importFiles.length === 0) { + return { success: false, warnings: [] }; + } + + const sourceFilePath = importFiles[0].uri; + await shim.fsDriver().copy(sourceFilePath, importTargetPath); + + + const status = await InteropService.instance().import({ + path: importTargetPath, + format: 'jex', + }); + + logger.info('Imported successfully'); + + return { success: true, warnings: status.warnings }; +}; + +const NoteImportButton: FunctionComponent = props => { + return ( + + ); +}; + +export default NoteImportButton; diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.tsx new file mode 100644 index 00000000000..7f0b3d05fac --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { Alert, Text, View } from 'react-native'; +import { _ } from '@joplin/lib/locale'; +import { ProgressBar } from 'react-native-paper'; +import { FunctionComponent, useCallback, useState } from 'react'; +import { ConfigScreenStyles } from '../configScreenStyles'; +import SettingsButton from '../SettingsButton'; +import Logger from '@joplin/utils/Logger'; + +// Undefined = indeterminant progress +export type OnProgressCallback = (progressFraction: number|undefined)=> void; +export type AfterCompleteListener = (success: boolean)=> Promise; +export type SetAfterCompleteListenerCallback = (listener: AfterCompleteListener)=> void; + +const logger = Logger.create('TaskButton'); + +interface TaskResult { + warnings: string[]; + success: boolean; +} + +export enum TaskStatus { + NotStarted, + InProgress, + Done, +} + +interface Props { + taskName: string; + title: (status: TaskStatus)=> string; + description?: string; + styles: ConfigScreenStyles; + onRunTask: ( + setProgress: OnProgressCallback, + setAfterCompleteListener: SetAfterCompleteListenerCallback, + )=> Promise; +} + +const TaskButton: FunctionComponent = props => { + const [taskStatus, setTaskStatus] = useState(TaskStatus.NotStarted); + const [progress, setProgress] = useState(0); + const [warnings, setWarnings] = useState(''); + + const startTask = useCallback(async () => { + // Don't run multiple task instances at the same time. + if (taskStatus === TaskStatus.InProgress) { + return; + } + + logger.info(`Starting task: ${props.taskName}`); + + setTaskStatus(TaskStatus.InProgress); + let completedSuccessfully = false; + let afterCompleteListener: AfterCompleteListener = async () => {}; + + try { + // Initially, undetermined progress + setProgress(undefined); + + const status = await props.onRunTask(setProgress, (afterComplete: AfterCompleteListener) => { + afterCompleteListener = afterComplete; + }); + + setWarnings(status.warnings.join('\n')); + if (status.success) { + setTaskStatus(TaskStatus.Done); + completedSuccessfully = true; + } + } catch (error) { + logger.error(`Task ${props.taskName} failed`); + Alert.alert(_('Error'), _('Task "%s" failed with error: %s', props.taskName, error.toString())); + } finally { + if (!completedSuccessfully) { + setTaskStatus(TaskStatus.NotStarted); + } + + await afterCompleteListener(completedSuccessfully); + } + }, [props.onRunTask, props.taskName, taskStatus]); + + if (taskStatus === TaskStatus.NotStarted || taskStatus === TaskStatus.InProgress) { + const progressComponent = ( + + ); + + const startOrCancelExportButton = ( + + ); + + return startOrCancelExportButton; + } else { + const warningComponent = ( + + {_('Warnings:\n%s', warnings)} + + ); + + const taskSummary = ( + + {props.title(taskStatus)} + {warnings.length > 0 ? warningComponent : null} + + ); + return taskSummary; + } +}; + +export default TaskButton; diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.ts b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.ts index 16d9e090f9b..88fadcdc871 100644 --- a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.ts +++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.ts @@ -1,15 +1,6 @@ import Folder from '@joplin/lib/models/Folder'; import InteropService from '@joplin/lib/services/interop/InteropService'; import { ExportModuleOutputFormat, ExportOptions, FileSystemItem, OnExportProgressCallback } from '@joplin/lib/services/interop/types'; -import shim from '@joplin/lib/shim'; - -import { CachesDirectoryPath } from 'react-native-fs'; -export const makeExportCacheDirectory = async () => { - const targetDir = `${CachesDirectoryPath}/exports`; - await shim.fsDriver().mkdir(targetDir); - - return targetDir; -}; const exportFolders = async (path: string, onProgress: OnExportProgressCallback) => { const folders = await Folder.all(); diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.ts b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.ts new file mode 100644 index 00000000000..68419dc8631 --- /dev/null +++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.ts @@ -0,0 +1,12 @@ + +import shim from '@joplin/lib/shim'; +import { CachesDirectoryPath } from 'react-native-fs'; + +const makeExportCacheDirectory = async () => { + const targetDir = `${CachesDirectoryPath}/exports`; + await shim.fsDriver().mkdir(targetDir); + + return targetDir; +}; + +export default makeExportCacheDirectory;