diff --git a/.eslintignore b/.eslintignore
index 29e931cb3..cef372f88 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -567,9 +567,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 862f80aa9..e01e1cfd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -547,9 +547,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 c4859dcb2..82a935924 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';
@@ -31,6 +31,7 @@ import { Button, TextInput } from 'react-native-paper';
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
+import NoteImportButton, { importButtonDefaultTitle, importButtonDescription } from './NoteExportSection/NoteImportButton';
interface ConfigScreenState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@@ -524,10 +525,14 @@ 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 29b991158..6613ee38e 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,67 @@ 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 getTitle = (taskStatus: TaskStatus) => {
+ if (taskStatus === TaskStatus.InProgress) {
+ return _('Exporting...');
+ } else {
+ return exportButtonDefaultTitle();
+ }
+};
- 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);
- }
- });
-
- 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);
+ });
- if (exportStatus === ExportStatus.NotStarted || exportStatus === ExportStatus.Exporting) {
- const progressComponent = (
-
- );
+ // Initially, undetermined progress
+ onProgress(undefined);
- const startOrCancelExportButton = (
-
- );
+ 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);
+ }
+ });
- return startOrCancelExportButton;
- } else {
- const warningComponent = (
-
- {_('Warnings:\n%s', warnings)}
-
- );
+ onProgress(1);
- const exportSummary = (
-
- {_('Exported successfully!')}
- {warnings.length > 0 ? warningComponent : null}
-
- );
- return exportSummary;
- }
+ logger.info('Export complete');
+
+ return { warnings: status.warnings, success: true };
+};
+
+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 000000000..86cc71194
--- /dev/null
+++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.tsx
@@ -0,0 +1,83 @@
+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';
+import { Platform } from 'react-native';
+
+const logger = Logger.create('NoteImportButton');
+
+interface Props {
+ styles: ConfigScreenStyles;
+}
+
+// Exported for search filtering
+export const importButtonDefaultTitle = () => _('Import from JEX');
+export const importButtonDescription = () => _('Import notes from a JEX (Joplin Export) file.');
+
+const getTitle = (taskStatus: TaskStatus) => {
+ if (taskStatus === TaskStatus.InProgress) {
+ return _('Importing...');
+ } else {
+ return importButtonDefaultTitle();
+ }
+};
+
+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) {
+ logger.info('Canceled.');
+ return { success: false, warnings: [] };
+ }
+
+ const sourceFileUri = importFiles[0].uri;
+ const sourceFilePath = Platform.select({
+ android: sourceFileUri,
+ ios: decodeURI(sourceFileUri),
+ });
+ await shim.fsDriver().copy(sourceFilePath, importTargetPath);
+
+ try {
+ const status = await InteropService.instance().import({
+ path: importTargetPath,
+ format: 'jex',
+ });
+
+ logger.info('Imported successfully');
+ return { success: true, warnings: status.warnings };
+ } catch (error) {
+ logger.error('Import failed with error', error);
+ throw new Error(_('Import failed. Make sure a JEX file was selected.\nDetails: %s', error.toString()));
+ }
+};
+
+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 000000000..f0c344193
--- /dev/null
+++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.tsx
@@ -0,0 +1,114 @@
+import * as React from 'react';
+import { Alert, Text } 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 = indeterminate 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;
+ buttonLabel: (status: TaskStatus)=> string;
+ finishedLabel: 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`, error);
+ 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]);
+
+ let statusComponent = (
+
+ );
+ if (taskStatus === TaskStatus.Done && warnings.length > 0) {
+ statusComponent = (
+
+ {_('Completed with warnings:\n%s', warnings)}
+
+ );
+ }
+
+ let buttonDescription = props.description;
+ if (taskStatus === TaskStatus.Done) {
+ buttonDescription = props.finishedLabel;
+ }
+
+ return (
+
+ );
+};
+
+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 16d9e090f..88fadcdc8 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 000000000..95cf22d96
--- /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 makeImportExportCacheDirectory = async () => {
+ const targetDir = `${CachesDirectoryPath}/exports`;
+ await shim.fsDriver().mkdir(targetDir);
+
+ return targetDir;
+};
+
+export default makeImportExportCacheDirectory;
diff --git a/packages/lib/components/shared/config/config-shared.ts b/packages/lib/components/shared/config/config-shared.ts
index baf2a3e39..bcc81fb0b 100644
--- a/packages/lib/components/shared/config/config-shared.ts
+++ b/packages/lib/components/shared/config/config-shared.ts
@@ -236,7 +236,7 @@ export const settingsSections = createSelector(
});
} else {
output.push(...([
- 'tools', 'export', 'moreInfo',
+ 'tools', 'importOrExport', 'moreInfo',
].map(name => {
return {
name,
diff --git a/packages/lib/models/Setting.ts b/packages/lib/models/Setting.ts
index 36e974fe6..857a0fe45 100644
--- a/packages/lib/models/Setting.ts
+++ b/packages/lib/models/Setting.ts
@@ -2711,7 +2711,7 @@ class Setting extends BaseModel {
'server',
'keymap',
'tools',
- 'export',
+ 'importOrExport',
'moreInfo',
];
}
@@ -2776,7 +2776,7 @@ class Setting extends BaseModel {
if (name === 'keymap') return _('Keyboard Shortcuts');
if (name === 'joplinCloud') return _('Joplin Cloud');
if (name === 'tools') return _('Tools');
- if (name === 'export') return _('Export');
+ if (name === 'importOrExport') return _('Import and Export');
if (name === 'moreInfo') return _('More information');
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label;
@@ -2804,7 +2804,7 @@ class Setting extends BaseModel {
'note': _('Geolocation, spellcheck, editor toolbar, image resize'),
'revisionService': _('Toggle note history, keep notes for'),
'tools': _('Logs, profiles, sync status'),
- 'export': _('Export your data'),
+ 'importOrExport': _('Import or export your data'),
'plugins': _('Enable or disable plugins'),
'moreInfo': _('Donate, website'),
};
@@ -2846,7 +2846,7 @@ class Setting extends BaseModel {
'keymap': 'fa fa-keyboard',
'joplinCloud': 'fa fa-cloud',
'tools': 'fa fa-toolbox',
- 'export': 'fa fa-file-export',
+ 'importOrExport': 'fa fa-file-export',
'moreInfo': 'fa fa-info-circle',
};