Skip to content

Commit

Permalink
Mobile: Support importing from JEX files (#10269)
Browse files Browse the repository at this point in the history
  • Loading branch information
personalizedrefrigerator committed Apr 8, 2024
1 parent ce67291 commit 2ae08ff
Show file tree
Hide file tree
Showing 10 changed files with 278 additions and 100 deletions.
3 changes: 3 additions & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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.') });
}

if (section.name === 'export') {
if (section.name === 'importOrExport') {
addSettingComponent(
<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(
<ExportDebugReportButton key='export_report_button' styles={this.styles()}/>,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,116 +1,83 @@
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');

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> = props => {
const [exportStatus, setExportStatus] = useState<ExportStatus>(ExportStatus.NotStarted);
const [exportProgress, setExportProgress] = useState<number|undefined>(0);
const [warnings, setWarnings] = useState<string>('');

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.InProgress) {
return _('Exporting...');
} else {
return exportButtonDefaultTitle();
}
};

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 = (
<ProgressBar
visible={exportStatus === ExportStatus.Exporting}
indeterminate={exportProgress === undefined}
progress={exportProgress}/>
);
onProgress(1);

const startOrCancelExportButton = (
<SettingsButton
title={exportStatus === ExportStatus.Exporting ? _('Exporting...') : exportButtonTitle()}
disabled={exportStatus === ExportStatus.Exporting}
description={exportStatus === ExportStatus.NotStarted ? exportButtonDescription() : null}
statusComponent={progressComponent}
clickHandler={startExport}
styles={props.styles}
/>
);
logger.info('Export complete');

return startOrCancelExportButton;
} else {
const warningComponent = (
<Text style={props.styles.styleSheet.warningText}>
{_('Warnings:\n%s', warnings)}
</Text>
);
return { warnings: status.warnings, success: true };
};

const exportSummary = (
<View style={props.styles.styleSheet.settingContainer}>
<Text style={props.styles.styleSheet.descriptionText}>{_('Exported successfully!')}</Text>
{warnings.length > 0 ? warningComponent : null}
</View>
);
return exportSummary;
}
const NoteExportButton: FunctionComponent<Props> = props => {
return (
<TaskButton
taskName={exportButtonDefaultTitle()}
buttonLabel={getTitle}
finishedLabel={_('Exported successfully!')}
description={exportButtonDescription()}
styles={props.styles}
onRunTask={runExportTask}
/>
);
};

export default NoteExportButton;
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 2ae08ff

Please sign in to comment.