1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-01-23 18:53:36 +02:00

Mobile: Support importing from JEX files (#10269)

This commit is contained in:
Henry Heino 2024-04-08 09:57:01 -07:00 committed by GitHub
parent ce672915da
commit 2ae08ff46e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 278 additions and 100 deletions

View File

@ -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/ExportProfileButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.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/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/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.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/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/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js

3
.gitignore vendored
View File

@ -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/ExportProfileButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.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/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/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.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/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/SectionHeader.js
packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js

View File

@ -18,7 +18,7 @@ import * as shared from '@joplin/lib/components/shared/config/config-shared';
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import biometricAuthenticate from '../../biometrics/biometricAuthenticate'; import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles'; import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles';
import NoteExportButton, { exportButtonDescription, exportButtonTitle } from './NoteExportSection/NoteExportButton'; import NoteExportButton, { exportButtonDescription, exportButtonDefaultTitle } from './NoteExportSection/NoteExportButton';
import SettingsButton from './SettingsButton'; import SettingsButton from './SettingsButton';
import Clipboard from '@react-native-community/clipboard'; import Clipboard from '@react-native-community/clipboard';
import { ReactElement, ReactNode } from 'react'; 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 PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates'; import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton'; import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
import NoteImportButton, { importButtonDefaultTitle, importButtonDescription } from './NoteExportSection/NoteImportButton';
interface ConfigScreenState { interface ConfigScreenState {
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
@ -524,10 +525,14 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
addSettingButton('fix_search_engine_index', this.state.fixingSearchIndex ? _('Fixing search index...') : _('Fix search index'), this.fixSearchEngineIndexButtonPress_, { disabled: this.state.fixingSearchIndex, description: _('Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.') }); addSettingButton('fix_search_engine_index', this.state.fixingSearchIndex ? _('Fixing search index...') : _('Fix search index'), this.fixSearchEngineIndexButtonPress_, { disabled: this.state.fixingSearchIndex, description: _('Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.') });
} }
if (section.name === 'export') { if (section.name === 'importOrExport') {
addSettingComponent( addSettingComponent(
<NoteExportButton key='export_as_jex_button' styles={this.styles()} />, <NoteExportButton key='export_as_jex_button' styles={this.styles()} />,
[exportButtonTitle(), exportButtonDescription()], [exportButtonDefaultTitle(), exportButtonDescription()],
);
addSettingComponent(
<NoteImportButton key='import_as_jex_button' styles={this.styles()} />,
[importButtonDefaultTitle(), importButtonDescription()],
); );
addSettingComponent( addSettingComponent(
<ExportDebugReportButton key='export_report_button' styles={this.styles()}/>, <ExportDebugReportButton key='export_report_button' styles={this.styles()}/>,

View File

@ -1,16 +1,15 @@
import * as React from 'react'; import * as React from 'react';
import { Text, Alert, View } from 'react-native';
import { _ } from '@joplin/lib/locale'; import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import { ProgressBar } from 'react-native-paper'; import { FunctionComponent } from 'react';
import { FunctionComponent, useCallback, useState } from 'react';
import shim from '@joplin/lib/shim'; import shim from '@joplin/lib/shim';
import { join } from 'path'; import { join } from 'path';
import Share from 'react-native-share'; 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 { ExportProgressState } from '@joplin/lib/services/interop/types';
import { ConfigScreenStyles } from '../configScreenStyles'; 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'); const logger = Logger.create('NoteExportButton');
@ -18,99 +17,67 @@ interface Props {
styles: ConfigScreenStyles; styles: ConfigScreenStyles;
} }
enum ExportStatus { export const exportButtonDefaultTitle = () => _('Export all notes as JEX');
NotStarted,
Exporting,
Exported,
}
export const exportButtonTitle = () => _('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.'); 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> = props => { const getTitle = (taskStatus: TaskStatus) => {
const [exportStatus, setExportStatus] = useState<ExportStatus>(ExportStatus.NotStarted); if (taskStatus === TaskStatus.InProgress) {
const [exportProgress, setExportProgress] = useState<number|undefined>(0); return _('Exporting...');
const [warnings, setWarnings] = useState<string>(''); } else {
return exportButtonDefaultTitle();
}
};
const startExport = useCallback(async () => { const runExportTask = async (
// Don't run multiple exports at the same time. onProgress: OnProgressCallback,
if (exportStatus === ExportStatus.Exporting) { setAfterCompleteListener: SetAfterCompleteListenerCallback,
return; ) => {
} const exportTargetPath = join(await makeImportExportCacheDirectory(), 'jex-export.jex');
logger.info(`Exporting all folders to path ${exportTargetPath}`);
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'));
setAfterCompleteListener(async (success: boolean) => {
if (success) {
await Share.open({ await Share.open({
type: 'application/jex', type: 'application/jex',
filename: 'export.jex', filename: 'export.jex',
url: `file://${exportTargetPath}`, url: `file://${exportTargetPath}`,
failOnCancel: false, 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) { // Initially, undetermined progress
const progressComponent = ( onProgress(undefined);
<ProgressBar
visible={exportStatus === ExportStatus.Exporting}
indeterminate={exportProgress === undefined}
progress={exportProgress}/>
);
const startOrCancelExportButton = ( const status = await exportAllFolders(exportTargetPath, (status, progress) => {
<SettingsButton if (progress !== null) {
title={exportStatus === ExportStatus.Exporting ? _('Exporting...') : exportButtonTitle()} onProgress(progress);
disabled={exportStatus === ExportStatus.Exporting} } else if (status === ExportProgressState.Closing || status === ExportProgressState.QueuingItems) {
description={exportStatus === ExportStatus.NotStarted ? exportButtonDescription() : null} // We don't have a numeric progress value and the closing/queuing state may take a while.
statusComponent={progressComponent} // Set a special progress value:
clickHandler={startExport} onProgress(undefined);
styles={props.styles} }
/> });
);
return startOrCancelExportButton; onProgress(1);
} else {
const warningComponent = (
<Text style={props.styles.styleSheet.warningText}>
{_('Warnings:\n%s', warnings)}
</Text>
);
const exportSummary = ( logger.info('Export complete');
<View style={props.styles.styleSheet.settingContainer}>
<Text style={props.styles.styleSheet.descriptionText}>{_('Exported successfully!')}</Text> return { warnings: status.warnings, success: true };
{warnings.length > 0 ? warningComponent : null} };
</View>
); const NoteExportButton: FunctionComponent<Props> = props => {
return exportSummary; return (
} <TaskButton
taskName={exportButtonDefaultTitle()}
buttonLabel={getTitle}
finishedLabel={_('Exported successfully!')}
description={exportButtonDescription()}
styles={props.styles}
onRunTask={runExportTask}
/>
);
}; };
export default NoteExportButton; export default NoteExportButton;

View File

@ -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> = props => {
return (
<TaskButton
taskName={importButtonDefaultTitle()}
description={importButtonDescription()}
buttonLabel={getTitle}
finishedLabel={_('Imported successfully!')}
styles={props.styles}
onRunTask={runImportTask}
/>
);
};
export default NoteImportButton;

View File

@ -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<void>;
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<TaskResult>;
}
const TaskButton: FunctionComponent<Props> = props => {
const [taskStatus, setTaskStatus] = useState<TaskStatus>(TaskStatus.NotStarted);
const [progress, setProgress] = useState<number|undefined>(0);
const [warnings, setWarnings] = useState<string>('');
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 = (
<ProgressBar
visible={taskStatus === TaskStatus.InProgress}
indeterminate={progress === undefined}
progress={progress}
/>
);
if (taskStatus === TaskStatus.Done && warnings.length > 0) {
statusComponent = (
<Text style={props.styles.styleSheet.warningText}>
{_('Completed with warnings:\n%s', warnings)}
</Text>
);
}
let buttonDescription = props.description;
if (taskStatus === TaskStatus.Done) {
buttonDescription = props.finishedLabel;
}
return (
<SettingsButton
title={props.buttonLabel(taskStatus)}
disabled={taskStatus === TaskStatus.InProgress}
description={buttonDescription}
statusComponent={statusComponent}
clickHandler={startTask}
styles={props.styles}
/>
);
};
export default TaskButton;

View File

@ -1,15 +1,6 @@
import Folder from '@joplin/lib/models/Folder'; import Folder from '@joplin/lib/models/Folder';
import InteropService from '@joplin/lib/services/interop/InteropService'; import InteropService from '@joplin/lib/services/interop/InteropService';
import { ExportModuleOutputFormat, ExportOptions, FileSystemItem, OnExportProgressCallback } from '@joplin/lib/services/interop/types'; 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 exportFolders = async (path: string, onProgress: OnExportProgressCallback) => {
const folders = await Folder.all(); const folders = await Folder.all();

View File

@ -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;

View File

@ -236,7 +236,7 @@ export const settingsSections = createSelector(
}); });
} else { } else {
output.push(...([ output.push(...([
'tools', 'export', 'moreInfo', 'tools', 'importOrExport', 'moreInfo',
].map(name => { ].map(name => {
return { return {
name, name,

View File

@ -2711,7 +2711,7 @@ class Setting extends BaseModel {
'server', 'server',
'keymap', 'keymap',
'tools', 'tools',
'export', 'importOrExport',
'moreInfo', 'moreInfo',
]; ];
} }
@ -2776,7 +2776,7 @@ class Setting extends BaseModel {
if (name === 'keymap') return _('Keyboard Shortcuts'); if (name === 'keymap') return _('Keyboard Shortcuts');
if (name === 'joplinCloud') return _('Joplin Cloud'); if (name === 'joplinCloud') return _('Joplin Cloud');
if (name === 'tools') return _('Tools'); if (name === 'tools') return _('Tools');
if (name === 'export') return _('Export'); if (name === 'importOrExport') return _('Import and Export');
if (name === 'moreInfo') return _('More information'); if (name === 'moreInfo') return _('More information');
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label; 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'), 'note': _('Geolocation, spellcheck, editor toolbar, image resize'),
'revisionService': _('Toggle note history, keep notes for'), 'revisionService': _('Toggle note history, keep notes for'),
'tools': _('Logs, profiles, sync status'), 'tools': _('Logs, profiles, sync status'),
'export': _('Export your data'), 'importOrExport': _('Import or export your data'),
'plugins': _('Enable or disable plugins'), 'plugins': _('Enable or disable plugins'),
'moreInfo': _('Donate, website'), 'moreInfo': _('Donate, website'),
}; };
@ -2846,7 +2846,7 @@ class Setting extends BaseModel {
'keymap': 'fa fa-keyboard', 'keymap': 'fa fa-keyboard',
'joplinCloud': 'fa fa-cloud', 'joplinCloud': 'fa fa-cloud',
'tools': 'fa fa-toolbox', 'tools': 'fa fa-toolbox',
'export': 'fa fa-file-export', 'importOrExport': 'fa fa-file-export',
'moreInfo': 'fa fa-info-circle', 'moreInfo': 'fa fa-info-circle',
}; };