1
0
mirror of https://github.com/laurent22/joplin.git synced 2024-12-21 09:38:01 +02:00

Mobile: Add JEX export (#8428)

This commit is contained in:
Henry Heino 2023-07-18 06:58:06 -07:00 committed by GitHub
parent ac66332a4e
commit 6ce8865719
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 765 additions and 153 deletions

View File

@ -422,7 +422,12 @@ packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/getResponsiveValue.test.js packages/app-mobile/components/getResponsiveValue.test.js
packages/app-mobile/components/screens/ConfigScreen.js packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/Note.js packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js

7
.gitignore vendored
View File

@ -407,7 +407,12 @@ packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.js packages/app-mobile/components/getResponsiveValue.js
packages/app-mobile/components/getResponsiveValue.test.js packages/app-mobile/components/getResponsiveValue.test.js
packages/app-mobile/components/screens/ConfigScreen.js packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.js
packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.js
packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.js
packages/app-mobile/components/screens/Note.js packages/app-mobile/components/screens/Note.js
packages/app-mobile/components/screens/Notes.js packages/app-mobile/components/screens/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js

View File

@ -1,29 +1,32 @@
/* eslint-disable @typescript-eslint/explicit-member-accessibility */ /* eslint-disable @typescript-eslint/explicit-member-accessibility */
import Slider from '@react-native-community/slider'; import Slider from '@react-native-community/slider';
const React = require('react'); const React = require('react');
import { Platform, Linking, View, Switch, StyleSheet, ScrollView, Text, Button, TouchableOpacity, TextInput, Alert, PermissionsAndroid, TouchableNativeFeedback } from 'react-native'; import { Platform, Linking, View, Switch, ScrollView, Text, Button, TouchableOpacity, TextInput, Alert, PermissionsAndroid, TouchableNativeFeedback } from 'react-native';
import Setting, { AppType } from '@joplin/lib/models/Setting'; import Setting, { AppType } from '@joplin/lib/models/Setting';
import NavService from '@joplin/lib/services/NavService'; import NavService from '@joplin/lib/services/NavService';
import ReportService from '@joplin/lib/services/ReportService'; import ReportService from '@joplin/lib/services/ReportService';
import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine'; import SearchEngine from '@joplin/lib/services/searchengine/SearchEngine';
import checkPermissions from '../../utils/checkPermissions'; import checkPermissions from '../../../utils/checkPermissions';
import time from '@joplin/lib/time'; import time from '@joplin/lib/time';
import shim from '@joplin/lib/shim'; import shim from '@joplin/lib/shim';
import setIgnoreTlsErrors from '../../utils/TlsUtils'; import setIgnoreTlsErrors from '../../../utils/TlsUtils';
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
import { State } from '@joplin/lib/reducer'; import { State } from '@joplin/lib/reducer';
const { BackButtonService } = require('../../services/back-button.js'); const { BackButtonService } = require('../../../services/back-button.js');
const VersionInfo = require('react-native-version-info').default; const VersionInfo = require('react-native-version-info').default;
const { connect } = require('react-redux'); const { connect } = require('react-redux');
import ScreenHeader from '../ScreenHeader'; import ScreenHeader from '../../ScreenHeader';
const { _ } = require('@joplin/lib/locale'); const { _ } = require('@joplin/lib/locale');
const { BaseScreenComponent } = require('../base-screen.js'); const { BaseScreenComponent } = require('../../base-screen.js');
const { Dropdown } = require('../Dropdown.js'); const { Dropdown } = require('../../Dropdown');
const { themeStyle } = require('../global-style.js'); const { themeStyle } = require('../../global-style.js');
const shared = require('@joplin/lib/components/shared/config-shared.js'); const shared = require('@joplin/lib/components/shared/config-shared.js');
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
import { openDocumentTree } from '@joplin/react-native-saf-x'; import { openDocumentTree } from '@joplin/react-native-saf-x';
import biometricAuthenticate from '../biometrics/biometricAuthenticate'; import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
import configScreenStyles from './configScreenStyles';
import NoteExportButton from './NoteExportSection/NoteExportButton';
import ConfigScreenButton from './ConfigScreenButton';
class ConfigScreenComponent extends BaseScreenComponent { class ConfigScreenComponent extends BaseScreenComponent {
public static navigationOptions(): any { public static navigationOptions(): any {
@ -223,94 +226,11 @@ class ConfigScreenComponent extends BaseScreenComponent {
public styles() { public styles() {
const themeId = this.props.themeId; const themeId = this.props.themeId;
const theme = themeStyle(themeId);
if (this.styles_[themeId]) return this.styles_[themeId]; if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {}; this.styles_ = {};
const styles: any = { this.styles_[themeId] = configScreenStyles(themeId);
body: {
flex: 1,
justifyContent: 'flex-start',
flexDirection: 'column',
},
settingContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
},
settingText: {
color: theme.color,
fontSize: theme.fontSize,
flex: 1,
paddingRight: 5,
},
descriptionText: {
color: theme.colorFaded,
fontSize: theme.fontSizeSmaller,
flex: 1,
},
sliderUnits: {
color: theme.color,
fontSize: theme.fontSize,
marginRight: 10,
},
settingDescriptionText: {
color: theme.colorFaded,
fontSize: theme.fontSizeSmaller,
flex: 1,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingBottom: theme.marginBottom,
},
permissionText: {
color: theme.color,
fontSize: theme.fontSize,
flex: 1,
marginTop: 10,
},
settingControl: {
color: theme.color,
flex: 1,
},
textInput: {
color: theme.color,
},
};
styles.settingContainerNoBottomBorder = { ...styles.settingContainer, borderBottomWidth: 0,
paddingBottom: theme.marginBottom / 2 };
styles.settingControl.borderBottomWidth = 1;
styles.settingControl.borderBottomColor = theme.dividerColor;
styles.switchSettingText = { ...styles.settingText };
styles.switchSettingText.width = '80%';
styles.switchSettingContainer = { ...styles.settingContainer };
styles.switchSettingContainer.flexDirection = 'row';
styles.switchSettingContainer.justifyContent = 'space-between';
styles.linkText = { ...styles.settingText };
styles.linkText.borderBottomWidth = 1;
styles.linkText.borderBottomColor = theme.color;
styles.linkText.flex = 0;
styles.linkText.fontWeight = 'normal';
styles.headerWrapperStyle = { ...styles.settingContainer, ...theme.headerWrapperStyle };
styles.switchSettingControl = { ...styles.settingControl };
delete styles.switchSettingControl.color;
// styles.switchSettingControl.width = '20%';
styles.switchSettingControl.flex = 0;
this.styles_[themeId] = StyleSheet.create(styles);
return this.styles_[themeId]; return this.styles_[themeId];
} }
@ -388,28 +308,16 @@ class ConfigScreenComponent extends BaseScreenComponent {
); );
} }
renderButton(key: string, title: string, clickHandler: ()=> void, options: any = null) { private renderButton(key: string, title: string, clickHandler: ()=> void, options: any = null) {
if (!options) options = {};
let descriptionComp = null;
if (options.description) {
descriptionComp = (
<View style={{ flex: 1, marginTop: 10 }}>
<Text style={this.styles().descriptionText}>{options.description}</Text>
</View>
);
}
return ( return (
<View key={key} style={this.styles().settingContainer}> <ConfigScreenButton
<View style={{ flex: 1, flexDirection: 'column' }}> key={key}
<View style={{ flex: 1 }}> title={title}
<Button title={title} onPress={clickHandler} disabled={!!options.disabled} /> clickHandler={clickHandler}
</View> description={options?.description}
{options.statusComp} statusComponent={options?.statusComp}
{descriptionComp} styles={this.styles()}
</View> />
</View>
); );
} }
@ -642,12 +550,13 @@ class ConfigScreenComponent extends BaseScreenComponent {
settingComps.push(this.renderButton('profiles_buttons', _('Manage profiles'), this.manageProfilesButtonPress_)); settingComps.push(this.renderButton('profiles_buttons', _('Manage profiles'), this.manageProfilesButtonPress_));
settingComps.push(this.renderButton('status_button', _('Sync Status'), this.syncStatusButtonPress_)); settingComps.push(this.renderButton('status_button', _('Sync Status'), this.syncStatusButtonPress_));
settingComps.push(this.renderButton('log_button', _('Log'), this.logButtonPress_)); settingComps.push(this.renderButton('log_button', _('Log'), this.logButtonPress_));
if (Platform.OS === 'android') {
settingComps.push(this.renderButton('export_report_button', this.state.creatingReport ? _('Creating report...') : _('Export Debug Report'), this.exportDebugButtonPress_, { disabled: this.state.creatingReport }));
}
settingComps.push(this.renderButton('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.') })); settingComps.push(this.renderButton('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.') }));
settingComps.push(this.renderHeader('export', _('Export')));
settingComps.push(<NoteExportButton key={'export_as_jex_button'} styles={this.styles()} />);
if (shim.mobilePlatform() === 'android') { if (shim.mobilePlatform() === 'android') {
settingComps.push(this.renderButton('export_report_button', this.state.creatingReport ? _('Creating report...') : _('Export Debug Report'), this.exportDebugButtonPress_, { disabled: this.state.creatingReport }));
settingComps.push(this.renderButton('export_data', this.state.profileExportStatus === 'exporting' ? _('Exporting profile...') : _('Export profile'), this.exportProfileButtonPress_, { disabled: this.state.profileExportStatus === 'exporting', description: _('For debugging purpose only: export your profile to an external SD card.') })); settingComps.push(this.renderButton('export_data', this.state.profileExportStatus === 'exporting' ? _('Exporting profile...') : _('Export profile'), this.exportProfileButtonPress_, { disabled: this.state.profileExportStatus === 'exporting', description: _('For debugging purpose only: export your profile to an external SD card.') }));
if (this.state.profileExportStatus === 'prompt') { if (this.state.profileExportStatus === 'prompt') {

View File

@ -0,0 +1,38 @@
import * as React from 'react';
import { FunctionComponent, ReactNode } from 'react';
import { View, Text, Button } from 'react-native';
import { ConfigScreenStyles } from './configScreenStyles';
interface Props {
title: string;
description: string;
clickHandler: ()=> void;
styles: ConfigScreenStyles;
disabled?: boolean;
statusComponent?: ReactNode;
}
const ConfigScreenButton: FunctionComponent<Props> = props => {
let descriptionComp = null;
if (props.description) {
descriptionComp = (
<View style={{ flex: 1, marginTop: 10 }}>
<Text style={props.styles.descriptionText}>{props.description}</Text>
</View>
);
}
return (
<View style={props.styles.settingContainer}>
<View style={{ flex: 1, flexDirection: 'column' }}>
<View style={{ flex: 1 }}>
<Button title={props.title} onPress={props.clickHandler} disabled={!!props.disabled} />
</View>
{props.statusComponent}
{descriptionComp}
</View>
</View>
);
};
export default ConfigScreenButton;

View File

@ -0,0 +1,57 @@
/**
* @jest-environment jsdom
*/
import * as React from 'react';
import { setImmediate } from 'timers';
// Required by some libraries (setImmediate is not supported in most browsers,
// so is removed by jsdom).
window.setImmediate = setImmediate;
import { _ } from '@joplin/lib/locale';
import { act, fireEvent, render, waitFor } from '@testing-library/react-native';
import { expect, describe, beforeEach, test, jest } from '@jest/globals';
import '@testing-library/jest-native/extend-expect';
import { createNTestNotes, setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import Folder from '@joplin/lib/models/Folder';
import configScreenStyles from '../configScreenStyles';
import { type ShareOptions } from 'react-native-share';
import Setting from '@joplin/lib/models/Setting';
import NoteExportButton from './NoteExportButton';
jest.mock('react-native-share', () => {
const Share = {
open: (_options: ShareOptions) => jest.fn(),
};
return { default: Share };
});
describe('NoteExportButton', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
const folder1 = await Folder.save({ title: 'folder1' });
await createNTestNotes(10, folder1);
const folder2 = await Folder.save({ title: 'Folder 2 🙂' });
await createNTestNotes(10, folder2);
});
test('should show "Exported successfully!" after clicking "Export"', async () => {
const styles = configScreenStyles(Setting.THEME_DARK);
const view = render(<NoteExportButton
styles={styles}
/>);
const exportButton = view.getByText(_('Export all notes as JEX'));
await act(() => fireEvent.press(exportButton));
await waitFor(() =>
expect(view.queryByText(_('Exported successfully!'))).not.toBeNull()
);
// With the default folder setup, there should be no warnings
expect(view.queryByText(/Warnings/g)).toBeNull();
});
});

View File

@ -0,0 +1,114 @@
import * as React from 'react';
import { Text, Alert, View } from 'react-native';
import { _ } from '@joplin/lib/locale';
import Logger from '@joplin/lib/Logger';
import { ProgressBar } from 'react-native-paper';
import { FunctionComponent, useCallback, useState } from 'react';
import shim from '@joplin/lib/shim';
import { join } from 'path';
import Share from 'react-native-share';
import exportAllFolders, { makeExportCacheDirectory } from './exportAllFolders';
import { ExportProgressState } from '@joplin/lib/services/interop/types';
import { ConfigScreenStyles } from '../configScreenStyles';
import ConfigScreenButton from '../ConfigScreenButton';
const logger = Logger.create('NoteExportButton');
interface Props {
styles: ConfigScreenStyles;
}
enum ExportStatus {
NotStarted,
Exporting,
Exported,
}
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);
}
});
setExportStatus(ExportStatus.Exported);
setWarnings(status.warnings.join('\n'));
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]);
if (exportStatus === ExportStatus.NotStarted || exportStatus === ExportStatus.Exporting) {
const progressComponent = (
<ProgressBar
visible={exportStatus === ExportStatus.Exporting}
indeterminate={exportProgress === undefined}
progress={exportProgress}/>
);
const descriptionText = _('Share a copy of all notes in a file format that can be imported by Joplin on a computer.');
const startOrCancelExportButton = (
<ConfigScreenButton
title={exportStatus === ExportStatus.Exporting ? _('Exporting...') : _('Export all notes as JEX')}
disabled={exportStatus === ExportStatus.Exporting}
description={exportStatus === ExportStatus.NotStarted ? descriptionText : null}
statusComponent={progressComponent}
clickHandler={startExport}
styles={props.styles}
/>
);
return startOrCancelExportButton;
} else {
const warningComponent = (
<Text style={props.styles.warningText}>
{_('Warnings:\n%s', warnings)}
</Text>
);
const exportSummary = (
<View style={props.styles.settingContainer}>
<Text style={props.styles.descriptionText}>{_('Exported successfully!')}</Text>
{warnings.length > 0 ? warningComponent : null}
</View>
);
return exportSummary;
}
};
export default NoteExportButton;

View File

@ -0,0 +1,29 @@
import Folder from '@joplin/lib/models/Folder';
import InteropService from '@joplin/lib/services/interop/InteropService';
import { 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();
const sourceFolderIds = folders.map(folder => folder.id);
const exportOptions: ExportOptions = {
sourceFolderIds,
path,
format: 'jex',
target: FileSystemItem.File,
onProgress,
};
return await InteropService.instance().export(exportOptions);
};
export default exportFolders;

View File

@ -0,0 +1,137 @@
import { TextStyle, ViewStyle, StyleSheet } from 'react-native';
const { themeStyle } = require('../../global-style.js');
export interface ConfigScreenStyles {
body: ViewStyle;
settingContainer: ViewStyle;
settingContainerNoBottomBorder: ViewStyle;
headerWrapperStyle: ViewStyle;
settingText: TextStyle;
linkText: TextStyle;
descriptionText: TextStyle;
warningText: TextStyle;
sliderUnits: TextStyle;
settingDescriptionText: TextStyle;
permissionText: TextStyle;
textInput: TextStyle;
switchSettingText: TextStyle;
switchSettingContainer: ViewStyle;
switchSettingControl: TextStyle;
settingControl: TextStyle;
}
const configScreenStyles = (themeId: number): ConfigScreenStyles => {
const theme = themeStyle(themeId);
const settingContainerStyle: ViewStyle = {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
paddingTop: theme.marginTop,
paddingBottom: theme.marginBottom,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
};
const settingTextStyle: TextStyle = {
color: theme.color,
fontSize: theme.fontSize,
flex: 1,
paddingRight: 5,
};
const settingControlStyle: TextStyle = {
color: theme.color,
flex: 1,
borderBottomWidth: 1,
borderBottomColor: theme.dividerColor,
};
const styles: ConfigScreenStyles = {
body: {
flex: 1,
justifyContent: 'flex-start',
flexDirection: 'column',
},
settingContainer: settingContainerStyle,
settingContainerNoBottomBorder: {
...settingContainerStyle,
borderBottomWidth: 0,
paddingBottom: theme.marginBottom / 2,
},
settingText: settingTextStyle,
descriptionText: {
color: theme.colorFaded,
fontSize: theme.fontSizeSmaller,
flex: 1,
},
linkText: {
...settingTextStyle,
borderBottomWidth: 1,
borderBottomColor: theme.color,
flex: 0,
fontWeight: 'normal',
},
warningText: {
color: theme.color,
backgroundColor: theme.warningBackgroundColor,
fontSize: theme.fontSizeSmaller,
},
sliderUnits: {
color: theme.color,
fontSize: theme.fontSize,
marginRight: 10,
},
settingDescriptionText: {
color: theme.colorFaded,
fontSize: theme.fontSizeSmaller,
flex: 1,
paddingLeft: theme.marginLeft,
paddingRight: theme.marginRight,
paddingBottom: theme.marginBottom,
},
permissionText: {
color: theme.color,
fontSize: theme.fontSize,
flex: 1,
marginTop: 10,
},
settingControl: settingControlStyle,
textInput: {
color: theme.color,
},
switchSettingText: {
...settingTextStyle,
width: '80%',
},
switchSettingContainer: {
...settingContainerStyle,
flexDirection: 'row',
justifyContent: 'space-between',
},
headerWrapperStyle: {
...settingContainerStyle,
...theme.headerWrapperStyle,
},
switchSettingControl: {
...settingControlStyle,
color: undefined,
flex: 0,
},
};
return StyleSheet.create(styles);
};
export default configScreenStyles;

View File

@ -5,6 +5,7 @@ module.exports = {
'ts', 'ts',
'tsx', 'tsx',
'js', 'js',
'jsx',
], ],
'transform': { 'transform': {
@ -14,6 +15,11 @@ module.exports = {
testMatch: ['**/*.test.(ts|tsx)'], testMatch: ['**/*.test.(ts|tsx)'],
testPathIgnorePatterns: ['<rootDir>/node_modules/'], testPathIgnorePatterns: ['<rootDir>/node_modules/'],
setupFilesAfterEnv: ['./jest.setup.js'],
// Do transform most packages in node_modules (transformations correct unrecognized
// import syntax)
transformIgnorePatterns: ['<rootDir>/node_modules/jest'],
slowTestThreshold: 40, slowTestThreshold: 40,
}; };

View File

@ -0,0 +1,35 @@
/* eslint-disable jest/require-top-level-describe */
const { afterEachCleanUp, afterAllCleanUp } = require('@joplin/lib/testing/test-utils.js');
const { shimInit } = require('@joplin/lib/shim-init-node.js');
const { mkdir, rm } = require('fs-extra');
const path = require('path');
const { tmpdir } = require('os');
const uuid = require('@joplin/lib/uuid').default;
const sqlite3 = require('sqlite3');
shimInit({ nodeSqlite: sqlite3 });
// react-native-fs's CachesDirectoryPath export doesn't work in a testing environment.
// Use a temporary folder instead.
const tempDirectoryPath = path.join(tmpdir(), `appmobile-test-${uuid.createNano()}`);
jest.doMock('react-native-fs', () => {
return {
CachesDirectoryPath: tempDirectoryPath,
};
});
beforeAll(async () => {
await mkdir(tempDirectoryPath);
});
afterEach(async () => {
await afterEachCleanUp();
});
afterAll(async () => {
await afterAllCleanUp();
await rm(tempDirectoryPath, { recursive: true });
});

View File

@ -20,8 +20,23 @@ const localPackages = {
'@joplin/fork-uslug': path.resolve(__dirname, '../fork-uslug/'), '@joplin/fork-uslug': path.resolve(__dirname, '../fork-uslug/'),
'@joplin/react-native-saf-x': path.resolve(__dirname, '../react-native-saf-x/'), '@joplin/react-native-saf-x': path.resolve(__dirname, '../react-native-saf-x/'),
'@joplin/react-native-alarm-notification': path.resolve(__dirname, '../react-native-alarm-notification/'), '@joplin/react-native-alarm-notification': path.resolve(__dirname, '../react-native-alarm-notification/'),
'@joplin/fork-sax': path.resolve(__dirname, '../fork-sax/'),
}; };
const remappedPackages = {
...localPackages,
};
// Some packages aren't available in react-native and thus must be replaced by browserified
// versions. For example, this allows us to `import {resolve} from 'path'` rather than
// `const { resolve } = require('path-browserify')` ('path-browerify' doesn't have its own type
// definitions).
const browserifiedPackages = ['path'];
for (const package of browserifiedPackages) {
remappedPackages[package] = path.resolve(__dirname, `./node_modules/${package}-browserify/`);
}
const watchedFolders = []; const watchedFolders = [];
for (const [, v] of Object.entries(localPackages)) { for (const [, v] of Object.entries(localPackages)) {
watchedFolders.push(v); watchedFolders.push(v);
@ -49,7 +64,7 @@ module.exports = {
// included in your reusable module as they would be imported when // included in your reusable module as they would be imported when
// the module is actually used. // the module is actually used.
// //
localPackages, remappedPackages,
{ {
get: (target, name) => { get: (target, name) => {
if (target.hasOwnProperty(name)) { if (target.hasOwnProperty(name)) {

View File

@ -37,6 +37,7 @@
"jsc-android": "241213.1.0", "jsc-android": "241213.1.0",
"lodash": "4.17.21", "lodash": "4.17.21",
"md5": "2.3.0", "md5": "2.3.0",
"path-browserify": "1.0.1",
"prop-types": "15.8.1", "prop-types": "15.8.1",
"punycode": "2.3.0", "punycode": "2.3.0",
"react": "18.2.0", "react": "18.2.0",
@ -79,6 +80,7 @@
"stream": "0.0.2", "stream": "0.0.2",
"stream-browserify": "3.0.0", "stream-browserify": "3.0.0",
"string-natural-compare": "3.0.1", "string-natural-compare": "3.0.1",
"tar-stream": "3.1.6",
"timers": "0.1.1", "timers": "0.1.1",
"url": "0.11.1" "url": "0.11.1"
}, },
@ -101,12 +103,15 @@
"@codemirror/view": "6.9.3", "@codemirror/view": "6.9.3",
"@joplin/tools": "~2.12", "@joplin/tools": "~2.12",
"@lezer/highlight": "1.1.4", "@lezer/highlight": "1.1.4",
"@testing-library/jest-native": "5.4.2",
"@testing-library/react-native": "12.1.2",
"@tsconfig/react-native": "2.0.2", "@tsconfig/react-native": "2.0.2",
"@types/fs-extra": "11.0.1", "@types/fs-extra": "11.0.1",
"@types/jest": "29.5.1", "@types/jest": "29.5.1",
"@types/react": "18.0.24", "@types/react": "18.0.24",
"@types/react-native": "0.70.6", "@types/react-native": "0.70.6",
"@types/react-redux": "7.1.25", "@types/react-redux": "7.1.25",
"@types/tar-stream": "2.2.2",
"babel-jest": "29.2.1", "babel-jest": "29.2.1",
"babel-plugin-module-resolver": "4.1.0", "babel-plugin-module-resolver": "4.1.0",
"execa": "4.1.0", "execa": "4.1.0",
@ -119,6 +124,8 @@
"md5-file": "5.0.0", "md5-file": "5.0.0",
"metro-react-native-babel-preset": "0.73.9", "metro-react-native-babel-preset": "0.73.9",
"nodemon": "2.0.22", "nodemon": "2.0.22",
"react-test-renderer": "18.2.0",
"sqlite3": "5.1.6",
"ts-jest": "29.1.0", "ts-jest": "29.1.0",
"ts-loader": "9.4.4", "ts-loader": "9.4.4",
"ts-node": "10.9.1", "ts-node": "10.9.1",

View File

@ -58,7 +58,7 @@ import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import Database from '@joplin/lib/database'; import Database from '@joplin/lib/database';
import NotesScreen from './components/screens/Notes'; import NotesScreen from './components/screens/Notes';
const { TagsScreen } = require('./components/screens/tags.js'); const { TagsScreen } = require('./components/screens/tags.js');
import ConfigScreen from './components/screens/ConfigScreen'; import ConfigScreen from './components/screens/ConfigScreen/ConfigScreen';
const { FolderScreen } = require('./components/screens/folder.js'); const { FolderScreen } = require('./components/screens/folder.js');
const { LogScreen } = require('./components/screens/log.js'); const { LogScreen } = require('./components/screens/log.js');
const { StatusScreen } = require('./components/screens/status.js'); const { StatusScreen } = require('./components/screens/status.js');
@ -423,6 +423,20 @@ function decryptionWorker_resourceMetadataButNotBlobDecrypted() {
ResourceFetcher.instance().scheduleAutoAddResources(); ResourceFetcher.instance().scheduleAutoAddResources();
} }
const initializeTempDir = async () => {
const tempDir = `${getProfilesRootDir()}/tmp`;
// Re-create the temporary directory.
try {
await shim.fsDriver().remove(tempDir);
} catch (_error) {
// The logger may not exist yet. Do nothing.
}
await shim.fsDriver().mkdir(tempDir);
return tempDir;
};
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
async function initialize(dispatch: Function) { async function initialize(dispatch: Function) {
shimInit(); shimInit();
@ -439,6 +453,7 @@ async function initialize(dispatch: Function) {
Setting.setConstant('env', __DEV__ ? 'dev' : 'prod'); Setting.setConstant('env', __DEV__ ? 'dev' : 'prod');
Setting.setConstant('appId', 'net.cozic.joplin-mobile'); Setting.setConstant('appId', 'net.cozic.joplin-mobile');
Setting.setConstant('appType', 'mobile'); Setting.setConstant('appType', 'mobile');
Setting.setConstant('tempDir', await initializeTempDir());
const resourceDir = getResourceDir(currentProfile, isSubProfile); const resourceDir = getResourceDir(currentProfile, isSubProfile);
Setting.setConstant('resourceDir', resourceDir); Setting.setConstant('resourceDir', resourceDir);

View File

@ -1,10 +1,16 @@
import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base'; import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base';
const RNFetchBlob = require('rn-fetch-blob').default; const RNFetchBlob = require('rn-fetch-blob').default;
const RNFS = require('react-native-fs'); import * as RNFS from 'react-native-fs';
const DocumentPicker = require('react-native-document-picker').default; const DocumentPicker = require('react-native-document-picker').default;
import { openDocument } from '@joplin/react-native-saf-x'; import { openDocument } from '@joplin/react-native-saf-x';
import RNSAF, { Encoding, DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x'; import RNSAF, { Encoding, DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x';
import { Platform } from 'react-native'; import { Platform } from 'react-native';
import * as tar from 'tar-stream';
import { resolve } from 'path';
import { Buffer } from 'buffer';
import Logger from '@joplin/lib/Logger';
const logger = Logger.create('fs-driver-rn');
const ANDROID_URI_PREFIX = 'content://'; const ANDROID_URI_PREFIX = 'content://';
@ -61,17 +67,13 @@ export default class FsDriverRN extends FsDriverBase {
}; };
} }
public async isDirectory(path: string): Promise<boolean> {
return (await this.stat(path)).isDirectory();
}
public async readDirStats(path: string, options: any = null) { public async readDirStats(path: string, options: any = null) {
if (!options) options = {}; if (!options) options = {};
if (!('recursive' in options)) options.recursive = false; if (!('recursive' in options)) options.recursive = false;
const isScoped = isScopedUri(path); const isScoped = isScopedUri(path);
let stats = []; let stats: any[] = [];
try { try {
if (isScoped) { if (isScoped) {
stats = await RNSAF.listFiles(path); stats = await RNSAF.listFiles(path);
@ -86,12 +88,15 @@ export default class FsDriverRN extends FsDriverBase {
for (let i = 0; i < stats.length; i++) { for (let i = 0; i < stats.length; i++) {
const stat = stats[i]; const stat = stats[i];
const relativePath = (isScoped ? stat.uri : stat.path).substr(path.length + 1); const relativePath = (isScoped ? stat.uri : stat.path).substr(path.length + 1);
output.push(this.rnfsStatToStd_(stat, relativePath)); const standardStat = this.rnfsStatToStd_(stat, relativePath);
output.push(standardStat);
if (isScoped) { if (isScoped) {
// readUriDirStatsHandleRecursion_ expects stat to have a URI property.
// Use the original stat.
output = await this.readUriDirStatsHandleRecursion_(stat, output, options); output = await this.readUriDirStatsHandleRecursion_(stat, output, options);
} else { } else {
output = await this.readDirStatsHandleRecursion_(path, stat, output, options); output = await this.readDirStatsHandleRecursion_(path, standardStat, output, options);
} }
} }
return output; return output;
@ -135,6 +140,8 @@ export default class FsDriverRN extends FsDriverBase {
await RNSAF.mkdir(path); await RNSAF.mkdir(path);
return; return;
} }
// Also creates parent directories: Works like mkdir -p
return RNFS.mkdir(path); return RNFS.mkdir(path);
} }
@ -148,8 +155,9 @@ export default class FsDriverRN extends FsDriverBase {
} }
return this.rnfsStatToStd_(r, path); return this.rnfsStatToStd_(r, path);
} catch (error) { } catch (error) {
if (error && ((error.message && error.message.indexOf('exist') >= 0) || error.code === 'ENOENT')) { if (error && (error.code === 'ENOENT' || !(await this.exists(path)))) {
// Probably { [Error: File does not exist] framesToPop: 1, code: 'EUNSPECIFIED' } // Probably { [Error: File does not exist] framesToPop: 1, code: 'EUNSPECIFIED' }
// or { [Error: The file {file} couldn’t be opened because there is no such file.], code: 'ENSCOCOAERRORDOMAIN260' }
// which unfortunately does not have a proper error code. Can be ignored. // which unfortunately does not have a proper error code. Can be ignored.
return null; return null;
} else { } else {
@ -260,6 +268,53 @@ export default class FsDriverRN extends FsDriverBase {
throw new Error(`Not implemented: md5File(): ${path}`); throw new Error(`Not implemented: md5File(): ${path}`);
} }
public async tarExtract(_options: any) {
throw new Error('Not implemented: tarExtract');
}
public async tarCreate(options: any, filePaths: string[]) {
// Choose a default cwd if not given
const cwd = options.cwd ?? RNFS.DocumentDirectoryPath;
const file = resolve(cwd, options.file);
if (await this.exists(file)) {
throw new Error('Error! Destination already exists');
}
const pack = tar.pack();
for (const path of filePaths) {
const absPath = resolve(cwd, path);
const stat = await this.stat(absPath);
const sizeBytes: number = stat.size;
const entry = pack.entry({ name: path, size: sizeBytes }, (error) => {
if (error) {
logger.error(`Tar error: ${error}`);
}
});
const chunkSize = 1024 * 100; // 100 KiB
for (let offset = 0; offset < sizeBytes; offset += chunkSize) {
// The RNFS documentation suggests using base64 for binary files.
const part = await RNFS.read(absPath, chunkSize, offset, 'base64');
entry.write(Buffer.from(part, 'base64'));
}
entry.end();
}
pack.finalize();
// The streams used by tar-stream seem not to support a chunk size
// (it seems despite the typings provided).
let data: number[]|null = null;
while ((data = pack.read()) !== null) {
const buff = Buffer.from(data);
const base64Data = buff.toString('base64');
await this.appendFile(file, base64Data, 'base64');
}
}
public async getExternalDirectoryPath(): Promise<string | undefined> { public async getExternalDirectoryPath(): Promise<string | undefined> {
let directory; let directory;
if (this.isUsingAndroidSAF()) { if (this.isUsingAndroidSAF()) {

View File

@ -1,4 +1,4 @@
import { ModuleType, FileSystemItem, ImportModuleOutputFormat, ImportOptions, ExportOptions, ImportExportResult } from './types'; import { ModuleType, FileSystemItem, ImportModuleOutputFormat, ImportOptions, ExportOptions, ImportExportResult, ExportProgressState } from './types';
import shim from '../../shim'; import shim from '../../shim';
import { _ } from '../../locale'; import { _ } from '../../locale';
import BaseItem from '../../models/BaseItem'; import BaseItem from '../../models/BaseItem';
@ -12,16 +12,13 @@ import InteropService_Importer_Jex from './InteropService_Importer_Jex';
import InteropService_Importer_Md from './InteropService_Importer_Md'; import InteropService_Importer_Md from './InteropService_Importer_Md';
import InteropService_Importer_Md_frontmatter from './InteropService_Importer_Md_frontmatter'; import InteropService_Importer_Md_frontmatter from './InteropService_Importer_Md_frontmatter';
import InteropService_Importer_Raw from './InteropService_Importer_Raw'; import InteropService_Importer_Raw from './InteropService_Importer_Raw';
import InteropService_Importer_EnexToMd from './InteropService_Importer_EnexToMd';
import InteropService_Importer_EnexToHtml from './InteropService_Importer_EnexToHtml';
import InteropService_Exporter_Jex from './InteropService_Exporter_Jex'; import InteropService_Exporter_Jex from './InteropService_Exporter_Jex';
import InteropService_Exporter_Raw from './InteropService_Exporter_Raw'; import InteropService_Exporter_Raw from './InteropService_Exporter_Raw';
import InteropService_Exporter_Md from './InteropService_Exporter_Md'; import InteropService_Exporter_Md from './InteropService_Exporter_Md';
import InteropService_Exporter_Md_frontmatter from './InteropService_Exporter_Md_frontmatter'; import InteropService_Exporter_Md_frontmatter from './InteropService_Exporter_Md_frontmatter';
import InteropService_Exporter_Html from './InteropService_Exporter_Html';
import InteropService_Importer_Base from './InteropService_Importer_Base'; import InteropService_Importer_Base from './InteropService_Importer_Base';
import InteropService_Exporter_Base from './InteropService_Exporter_Base'; import InteropService_Exporter_Base from './InteropService_Exporter_Base';
import Module, { makeExportModule, makeImportModule } from './Module'; import Module, { dynamicRequireModuleFactory, makeExportModule, makeImportModule } from './Module';
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const { fileExtension } = require('../../path-utils'); const { fileExtension } = require('../../path-utils');
const EventEmitter = require('events'); const EventEmitter = require('events');
@ -89,19 +86,18 @@ export default class InteropService {
fileExtensions: ['enex'], fileExtensions: ['enex'],
sources: [FileSystemItem.File], sources: [FileSystemItem.File],
description: _('Evernote Export File (as Markdown)'), description: _('Evernote Export File (as Markdown)'),
importerClass: 'InteropService_Importer_EnexToMd', supportsMobile: false,
isDefault: true, isDefault: true,
}, () => new InteropService_Importer_EnexToMd()), }, dynamicRequireModuleFactory('./InteropService_Importer_EnexToMd')),
makeImportModule({ makeImportModule({
format: 'enex', format: 'enex',
fileExtensions: ['enex'], fileExtensions: ['enex'],
sources: [FileSystemItem.File], sources: [FileSystemItem.File],
description: _('Evernote Export File (as HTML)'), description: _('Evernote Export File (as HTML)'),
// TODO: Consider doing this the same way as the multiple `md` importers are handled supportsMobile: false,
importerClass: 'InteropService_Importer_EnexToHtml',
outputFormat: ImportModuleOutputFormat.Html, outputFormat: ImportModuleOutputFormat.Html,
}, () => new InteropService_Importer_EnexToHtml()), }, dynamicRequireModuleFactory('./InteropService_Importer_EnexToHtml')),
]; ];
const exportModules = [ const exportModules = [
@ -136,13 +132,15 @@ export default class InteropService {
target: FileSystemItem.File, target: FileSystemItem.File,
isNoteArchive: false, isNoteArchive: false,
description: _('HTML File'), description: _('HTML File'),
}, () => new InteropService_Exporter_Html()), supportsMobile: false,
}, dynamicRequireModuleFactory('./InteropService_Exporter_Html')),
makeExportModule({ makeExportModule({
format: 'html', format: 'html',
target: FileSystemItem.Directory, target: FileSystemItem.Directory,
description: _('HTML Directory'), description: _('HTML Directory'),
}, () => new InteropService_Exporter_Html()), supportsMobile: false,
}, dynamicRequireModuleFactory('./InteropService_Exporter_Html')),
]; ];
this.defaultModules_ = (importModules as Module[]).concat(exportModules); this.defaultModules_ = (importModules as Module[]).concat(exportModules);
@ -164,8 +162,15 @@ export default class InteropService {
private findModuleByFormat_(type: ModuleType, format: string, target: FileSystemItem = null, outputFormat: ImportModuleOutputFormat = null) { private findModuleByFormat_(type: ModuleType, format: string, target: FileSystemItem = null, outputFormat: ImportModuleOutputFormat = null) {
const modules = this.modules(); const modules = this.modules();
const matches = []; const matches = [];
const isMobile = shim.mobilePlatform() !== '';
for (let i = 0; i < modules.length; i++) { for (let i = 0; i < modules.length; i++) {
const m = modules[i]; const m = modules[i];
if (!m.supportsMobile && isMobile) {
continue;
}
if (m.format === format && m.type === type) { if (m.format === format && m.type === type) {
if (!target && !outputFormat) { if (!target && !outputFormat) {
matches.push(m); matches.push(m);
@ -205,7 +210,7 @@ export default class InteropService {
// explicit with which importer we want to use. // explicit with which importer we want to use.
// //
// https://github.com/laurent22/joplin/pull/1795#pullrequestreview-281574417 // https://github.com/laurent22/joplin/pull/1795#pullrequestreview-281574417
private newModuleFromPath_(type: ModuleType, options: any) { private newModuleFromPath_(type: ModuleType, options: ExportOptions&ImportOptions) {
const moduleMetadata = this.findModuleByFormat_(type, options.format, options.target); const moduleMetadata = this.findModuleByFormat_(type, options.format, options.target);
if (!moduleMetadata) throw new Error(_('Cannot load "%s" module for format "%s" and target "%s"', type, options.format, options.target)); if (!moduleMetadata) throw new Error(_('Cannot load "%s" module for format "%s" and target "%s"', type, options.format, options.target));
@ -289,7 +294,11 @@ export default class InteropService {
const result: ImportExportResult = { warnings: [] }; const result: ImportExportResult = { warnings: [] };
const itemsToExport: any[] = []; const itemsToExport: any[] = [];
options.onProgress?.(ExportProgressState.QueuingItems, null);
let totalItemsToProcess = 0;
const queueExportItem = (itemType: number, itemOrId: any) => { const queueExportItem = (itemType: number, itemOrId: any) => {
totalItemsToProcess ++;
itemsToExport.push({ itemsToExport.push({
type: itemType, type: itemType,
itemOrId: itemOrId, itemOrId: itemOrId,
@ -373,6 +382,7 @@ export default class InteropService {
await exporter.prepareForProcessingItemType(type, itemsToExport); await exporter.prepareForProcessingItemType(type, itemsToExport);
} }
let itemsProcessed = 0;
for (let typeOrderIndex = 0; typeOrderIndex < typeOrder.length; typeOrderIndex++) { for (let typeOrderIndex = 0; typeOrderIndex < typeOrder.length; typeOrderIndex++) {
const type = typeOrder[typeOrderIndex]; const type = typeOrder[typeOrderIndex];
@ -414,9 +424,13 @@ export default class InteropService {
console.error(error); console.error(error);
result.warnings.push(error.message); result.warnings.push(error.message);
} }
itemsProcessed++;
options.onProgress?.(ExportProgressState.Exporting, itemsProcessed / totalItemsToProcess);
} }
} }
options.onProgress?.(ExportProgressState.Closing, null);
await exporter.close(); await exporter.close();
return result; return result;

View File

@ -1,6 +1,7 @@
/* eslint @typescript-eslint/no-unused-vars: 0, no-unused-vars: ["error", { "argsIgnorePattern": ".*" }], */ /* eslint @typescript-eslint/no-unused-vars: 0, no-unused-vars: ["error", { "argsIgnorePattern": ".*" }], */
import Setting from '../../models/Setting'; import Setting from '../../models/Setting';
import shim from '../../shim';
export default class InteropService_Exporter_Base { export default class InteropService_Exporter_Base {
private context_: any = {}; private context_: any = {};
@ -31,7 +32,7 @@ export default class InteropService_Exporter_Base {
protected async temporaryDirectory_(createIt: boolean) { protected async temporaryDirectory_(createIt: boolean) {
const md5 = require('md5'); const md5 = require('md5');
const tempDir = `${Setting.value('tempDir')}/${md5(Math.random() + Date.now())}`; const tempDir = `${Setting.value('tempDir')}/${md5(Math.random() + Date.now())}`;
if (createIt) await require('fs-extra').mkdirp(tempDir); if (createIt) await shim.fsDriver().mkdir(tempDir);
return tempDir; return tempDir;
} }
} }

View File

@ -3,8 +3,6 @@ import InteropService_Exporter_Base from './InteropService_Exporter_Base';
import InteropService_Exporter_Raw from './InteropService_Exporter_Raw'; import InteropService_Exporter_Raw from './InteropService_Exporter_Raw';
import shim from '../../shim'; import shim from '../../shim';
const fs = require('fs-extra');
export default class InteropService_Exporter_Jex extends InteropService_Exporter_Base { export default class InteropService_Exporter_Jex extends InteropService_Exporter_Base {
private tempDir_: string; private tempDir_: string;
@ -41,6 +39,6 @@ export default class InteropService_Exporter_Jex extends InteropService_Exporter
cwd: this.tempDir_, cwd: this.tempDir_,
}, filePaths); }, filePaths);
await fs.remove(this.tempDir_); await shim.fsDriver().remove(this.tempDir_);
} }
} }

View File

@ -3,6 +3,7 @@
import { ImportExportResult } from './types'; import { ImportExportResult } from './types';
import Setting from '../../models/Setting'; import Setting from '../../models/Setting';
import shim from '../../shim';
export default class InteropService_Importer_Base { export default class InteropService_Importer_Base {
@ -28,7 +29,7 @@ export default class InteropService_Importer_Base {
protected async temporaryDirectory_(createIt: boolean) { protected async temporaryDirectory_(createIt: boolean) {
const md5 = require('md5'); const md5 = require('md5');
const tempDir = `${Setting.value('tempDir')}/${md5(Math.random() + Date.now())}`; const tempDir = `${Setting.value('tempDir')}/${md5(Math.random() + Date.now())}`;
if (createIt) await require('fs-extra').mkdirp(tempDir); if (createIt) await shim.fsDriver().mkdir(tempDir);
return tempDir; return tempDir;
} }
} }

View File

@ -5,8 +5,6 @@ import InteropService_Importer_Raw from './InteropService_Importer_Raw';
const { filename } = require('../../path-utils'); const { filename } = require('../../path-utils');
import shim from '../../shim'; import shim from '../../shim';
const fs = require('fs-extra');
export default class InteropService_Importer_Jex extends InteropService_Importer_Base { export default class InteropService_Importer_Jex extends InteropService_Importer_Base {
public async exec(result: ImportExportResult) { public async exec(result: ImportExportResult) {
const tempDir = await this.temporaryDirectory_(true); const tempDir = await this.temporaryDirectory_(true);
@ -29,7 +27,7 @@ export default class InteropService_Importer_Jex extends InteropService_Importer
await importer.init(tempDir, this.options_); await importer.init(tempDir, this.options_);
result = await importer.exec(result); result = await importer.exec(result);
await fs.remove(tempDir); await shim.fsDriver().remove(tempDir);
return result; return result;
} }

View File

@ -1,4 +1,5 @@
import { _ } from '../../locale'; import { _ } from '../../locale';
import shim from '../../shim';
import InteropService_Exporter_Base from './InteropService_Exporter_Base'; import InteropService_Exporter_Base from './InteropService_Exporter_Base';
import InteropService_Importer_Base from './InteropService_Importer_Base'; import InteropService_Importer_Base from './InteropService_Importer_Base';
import { ExportOptions, FileSystemItem, ImportModuleOutputFormat, ImportOptions, ModuleType } from './types'; import { ExportOptions, FileSystemItem, ImportModuleOutputFormat, ImportOptions, ModuleType } from './types';
@ -10,6 +11,8 @@ interface BaseMetadata {
description: string; description: string;
isDefault: boolean; isDefault: boolean;
supportsMobile: boolean;
// Returns the full label to be displayed in the UI. // Returns the full label to be displayed in the UI.
fullLabel(moduleSource?: FileSystemItem): string; fullLabel(moduleSource?: FileSystemItem): string;
@ -24,7 +27,6 @@ interface ImportMetadata extends BaseMetadata {
type: ModuleType.Importer; type: ModuleType.Importer;
sources: FileSystemItem[]; sources: FileSystemItem[];
importerClass: string;
outputFormat: ImportModuleOutputFormat; outputFormat: ImportModuleOutputFormat;
} }
@ -47,6 +49,7 @@ const defaultBaseMetadata = {
fileExtensions: [] as string[], fileExtensions: [] as string[],
description: '', description: '',
isNoteArchive: true, isNoteArchive: true,
supportsMobile: true,
isDefault: false, isDefault: false,
}; };
@ -66,7 +69,6 @@ export const makeImportModule = (
...defaultBaseMetadata, ...defaultBaseMetadata,
type: ModuleType.Importer, type: ModuleType.Importer,
sources: [], sources: [],
importerClass: '',
outputFormat: ImportModuleOutputFormat.Markdown, outputFormat: ImportModuleOutputFormat.Markdown,
fullLabel: (moduleSource?: FileSystemItem) => { fullLabel: (moduleSource?: FileSystemItem) => {
@ -119,5 +121,16 @@ export const makeExportModule = (
}; };
}; };
// A module factory that uses dynamic requires.
// TODO: This is currently only used because some importers/exporters import libraries that
// don't work on mobile (e.g. htmlpack or fs). These importers/exporters should be migrated
// to fs so that this can be removed.
export const dynamicRequireModuleFactory = (fileName: string) => {
return () => {
const ModuleClass = shim.requireDynamic(fileName).default;
return new ModuleClass();
};
};
type Module = ImportModule|ExportModule; type Module = ImportModule|ExportModule;
export default Module; export default Module;

View File

@ -35,6 +35,14 @@ export interface ImportOptions {
outputFormat?: ImportModuleOutputFormat; outputFormat?: ImportModuleOutputFormat;
} }
export enum ExportProgressState {
QueuingItems,
Exporting,
Closing,
}
export type OnExportProgressCallback = (status: ExportProgressState, progress: number)=> void;
export interface ExportOptions { export interface ExportOptions {
format?: string; format?: string;
path?: string; path?: string;
@ -46,6 +54,8 @@ export interface ExportOptions {
plugins?: PluginStates; plugins?: PluginStates;
customCss?: string; customCss?: string;
packIntoSingleFile?: boolean; packIntoSingleFile?: boolean;
onProgress?: OnExportProgressCallback;
} }
export interface ImportExportResult { export interface ImportExportResult {

150
yarn.lock
View File

@ -4277,6 +4277,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@jest/schemas@npm:^29.6.0":
version: 29.6.0
resolution: "@jest/schemas@npm:29.6.0"
dependencies:
"@sinclair/typebox": ^0.27.8
checksum: c00511c69cf89138a7d974404d3a5060af375b5a52b9c87215d91873129b382ca11c1ff25bd6d605951404bb381ddce5f8091004a61e76457da35db1f5c51365
languageName: node
linkType: hard
"@jest/source-map@npm:^29.4.3": "@jest/source-map@npm:^29.4.3":
version: 29.4.3 version: 29.4.3
resolution: "@jest/source-map@npm:29.4.3" resolution: "@jest/source-map@npm:29.4.3"
@ -4535,12 +4544,15 @@ __metadata:
"@react-native-community/netinfo": 9.3.10 "@react-native-community/netinfo": 9.3.10
"@react-native-community/push-notification-ios": 1.11.0 "@react-native-community/push-notification-ios": 1.11.0
"@react-native-community/slider": 4.4.2 "@react-native-community/slider": 4.4.2
"@testing-library/jest-native": 5.4.2
"@testing-library/react-native": 12.1.2
"@tsconfig/react-native": 2.0.2 "@tsconfig/react-native": 2.0.2
"@types/fs-extra": 11.0.1 "@types/fs-extra": 11.0.1
"@types/jest": 29.5.1 "@types/jest": 29.5.1
"@types/react": 18.0.24 "@types/react": 18.0.24
"@types/react-native": 0.70.6 "@types/react-native": 0.70.6
"@types/react-redux": 7.1.25 "@types/react-redux": 7.1.25
"@types/tar-stream": 2.2.2
assert-browserify: 2.0.0 assert-browserify: 2.0.0
babel-jest: 29.2.1 babel-jest: 29.2.1
babel-plugin-module-resolver: 4.1.0 babel-plugin-module-resolver: 4.1.0
@ -4562,6 +4574,7 @@ __metadata:
md5-file: 5.0.0 md5-file: 5.0.0
metro-react-native-babel-preset: 0.73.9 metro-react-native-babel-preset: 0.73.9
nodemon: 2.0.22 nodemon: 2.0.22
path-browserify: 1.0.1
prop-types: 15.8.1 prop-types: 15.8.1
punycode: 2.3.0 punycode: 2.3.0
react: 18.2.0 react: 18.2.0
@ -4599,11 +4612,14 @@ __metadata:
react-native-webview: 12.4.0 react-native-webview: 12.4.0
react-native-zip-archive: 6.0.9 react-native-zip-archive: 6.0.9
react-redux: 8.0.7 react-redux: 8.0.7
react-test-renderer: 18.2.0
redux: 4.2.1 redux: 4.2.1
rn-fetch-blob: 0.12.0 rn-fetch-blob: 0.12.0
sqlite3: 5.1.6
stream: 0.0.2 stream: 0.0.2
stream-browserify: 3.0.0 stream-browserify: 3.0.0
string-natural-compare: 3.0.1 string-natural-compare: 3.0.1
tar-stream: 3.1.6
timers: 0.1.1 timers: 0.1.1
ts-jest: 29.1.0 ts-jest: 29.1.0
ts-loader: 9.4.4 ts-loader: 9.4.4
@ -7062,6 +7078,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@sinclair/typebox@npm:^0.27.8":
version: 0.27.8
resolution: "@sinclair/typebox@npm:0.27.8"
checksum: 00bd7362a3439021aa1ea51b0e0d0a0e8ca1351a3d54c606b115fdcc49b51b16db6e5f43b4fe7a28c38688523e22a94d49dd31168868b655f0d4d50f032d07a1
languageName: node
linkType: hard
"@sindresorhus/is@npm:^4.0.0": "@sindresorhus/is@npm:^4.0.0":
version: 4.2.0 version: 4.2.0
resolution: "@sindresorhus/is@npm:4.2.0" resolution: "@sindresorhus/is@npm:4.2.0"
@ -7212,6 +7235,23 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@testing-library/jest-native@npm:5.4.2":
version: 5.4.2
resolution: "@testing-library/jest-native@npm:5.4.2"
dependencies:
chalk: ^4.1.2
jest-diff: ^29.0.1
jest-matcher-utils: ^29.0.1
pretty-format: ^29.0.3
redent: ^3.0.0
peerDependencies:
react: ">=16.0.0"
react-native: ">=0.59"
react-test-renderer: ">=16.0.0"
checksum: 0c9e868a07a2bb0f4bec21213153d61a7fc464e9f24c8d47fde5c7851ddaa25ed5ac76fb92ca81f88d6af005bb5d89518fcceb1d49ac702f40ccf4967bee082e
languageName: node
linkType: hard
"@testing-library/react-hooks@npm:8.0.1": "@testing-library/react-hooks@npm:8.0.1":
version: 8.0.1 version: 8.0.1
resolution: "@testing-library/react-hooks@npm:8.0.1" resolution: "@testing-library/react-hooks@npm:8.0.1"
@ -7234,6 +7274,23 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@testing-library/react-native@npm:12.1.2":
version: 12.1.2
resolution: "@testing-library/react-native@npm:12.1.2"
dependencies:
pretty-format: ^29.0.0
peerDependencies:
jest: ">=28.0.0"
react: ">=16.8.0"
react-native: ">=0.59"
react-test-renderer: ">=16.8.0"
peerDependenciesMeta:
jest:
optional: true
checksum: 912fc961f213a8fa171b9b980d6f4edd8f11a012498fcf1b8e0d3ac1d20e85b61469a80914fda893aa48cb0d4b3f6075ec2723c58dae96eeac0ee1cd6e6daa3e
languageName: node
linkType: hard
"@tootallnate/once@npm:1": "@tootallnate/once@npm:1":
version: 1.1.2 version: 1.1.2
resolution: "@tootallnate/once@npm:1.1.2" resolution: "@tootallnate/once@npm:1.1.2"
@ -8086,6 +8143,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/tar-stream@npm:2.2.2":
version: 2.2.2
resolution: "@types/tar-stream@npm:2.2.2"
dependencies:
"@types/node": "*"
checksum: 4b33bc0d53770e952d6e2e8acb8889190510326a3e255d0c6edd94136d6027ecae939a7b49188d1d02d774328d9a3742ff633d505709d1a1200b3413c88d793d
languageName: node
linkType: hard
"@types/tough-cookie@npm:*": "@types/tough-cookie@npm:*":
version: 4.0.1 version: 4.0.1
resolution: "@types/tough-cookie@npm:4.0.1" resolution: "@types/tough-cookie@npm:4.0.1"
@ -9996,6 +10062,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"b4a@npm:^1.6.4":
version: 1.6.4
resolution: "b4a@npm:1.6.4"
checksum: 81b086f9af1f8845fbef4476307236bda3d660c158c201db976f19cdce05f41f93110ab6b12fd7a2696602a490cc43d5410ee36a56d6eef93afb0d6ca69ac3b2
languageName: node
linkType: hard
"babel-core@npm:^7.0.0-bridge.0": "babel-core@npm:^7.0.0-bridge.0":
version: 7.0.0-bridge.0 version: 7.0.0-bridge.0
resolution: "babel-core@npm:7.0.0-bridge.0" resolution: "babel-core@npm:7.0.0-bridge.0"
@ -16410,6 +16483,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"fast-fifo@npm:^1.1.0, fast-fifo@npm:^1.2.0":
version: 1.3.0
resolution: "fast-fifo@npm:1.3.0"
checksum: edc589b818eede61d0048f399daf67cbc5ef736588669482a20f37269b4808356e54ab89676fd8fa59b26c216c11e5ac57335cc70dca54fbbf692d4acde10de6
languageName: node
linkType: hard
"fast-glob@npm:^2.2.6": "fast-glob@npm:^2.2.6":
version: 2.2.7 version: 2.2.7
resolution: "fast-glob@npm:2.2.7" resolution: "fast-glob@npm:2.2.7"
@ -20450,6 +20530,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jest-diff@npm:^29.0.1, jest-diff@npm:^29.6.1":
version: 29.6.1
resolution: "jest-diff@npm:29.6.1"
dependencies:
chalk: ^4.0.0
diff-sequences: ^29.4.3
jest-get-type: ^29.4.3
pretty-format: ^29.6.1
checksum: c6350178ca27d92c7fd879790fb2525470c1ff1c5d29b1834a240fecd26c6904fb470ebddb98dc96dd85389c56c3b50e6965a1f5203e9236d213886ed9806219
languageName: node
linkType: hard
"jest-diff@npm:^29.3.1": "jest-diff@npm:^29.3.1":
version: 29.3.1 version: 29.3.1
resolution: "jest-diff@npm:29.3.1" resolution: "jest-diff@npm:29.3.1"
@ -20599,6 +20691,18 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jest-matcher-utils@npm:^29.0.1":
version: 29.6.1
resolution: "jest-matcher-utils@npm:29.6.1"
dependencies:
chalk: ^4.0.0
jest-diff: ^29.6.1
jest-get-type: ^29.4.3
pretty-format: ^29.6.1
checksum: d2efa6aed6e4820758b732b9fefd315c7fa4508ee690da656e1c5ac4c1a0f4cee5b04c9719ee1fda9aeb883b4209186c145089ced521e715b9fa70afdfa4a9c6
languageName: node
linkType: hard
"jest-matcher-utils@npm:^29.3.1": "jest-matcher-utils@npm:^29.3.1":
version: 29.3.1 version: 29.3.1
resolution: "jest-matcher-utils@npm:29.3.1" resolution: "jest-matcher-utils@npm:29.3.1"
@ -26222,6 +26326,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"path-browserify@npm:1.0.1":
version: 1.0.1
resolution: "path-browserify@npm:1.0.1"
checksum: c6d7fa376423fe35b95b2d67990060c3ee304fc815ff0a2dc1c6c3cfaff2bd0d572ee67e18f19d0ea3bbe32e8add2a05021132ac40509416459fffee35200699
languageName: node
linkType: hard
"path-browserify@npm:~0.0.0": "path-browserify@npm:~0.0.0":
version: 0.0.1 version: 0.0.1
resolution: "path-browserify@npm:0.0.1" resolution: "path-browserify@npm:0.0.1"
@ -27023,6 +27134,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"pretty-format@npm:^29.0.3, pretty-format@npm:^29.6.1":
version: 29.6.1
resolution: "pretty-format@npm:29.6.1"
dependencies:
"@jest/schemas": ^29.6.0
ansi-styles: ^5.0.0
react-is: ^18.0.0
checksum: 6f923a2379a37a425241dc223d76f671c73c4f37dba158050575a54095867d565c068b441843afdf3d7c37bed9df4bbadf46297976e60d4149972b779474203a
languageName: node
linkType: hard
"pretty-format@npm:^29.5.0": "pretty-format@npm:^29.5.0":
version: 29.5.0 version: 29.5.0
resolution: "pretty-format@npm:29.5.0" resolution: "pretty-format@npm:29.5.0"
@ -27442,6 +27564,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"queue-tick@npm:^1.0.1":
version: 1.0.1
resolution: "queue-tick@npm:1.0.1"
checksum: 57c3292814b297f87f792fbeb99ce982813e4e54d7a8bdff65cf53d5c084113913289d4a48ec8bbc964927a74b847554f9f4579df43c969a6c8e0f026457ad01
languageName: node
linkType: hard
"queue@npm:6.0.2": "queue@npm:6.0.2":
version: 6.0.2 version: 6.0.2
resolution: "queue@npm:6.0.2" resolution: "queue@npm:6.0.2"
@ -30918,6 +31047,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"streamx@npm:^2.15.0":
version: 2.15.0
resolution: "streamx@npm:2.15.0"
dependencies:
fast-fifo: ^1.1.0
queue-tick: ^1.0.1
checksum: 6f1dcdc326d57fa4ec0c2aade730b701d28e4e206047c230c6b3f6ac25b28f79809533342dd3e11861237dbd14f3af9ab83be972f569ccdf5eddc5c7ffeb657a
languageName: node
linkType: hard
"strict-uri-encode@npm:^2.0.0": "strict-uri-encode@npm:^2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "strict-uri-encode@npm:2.0.0" resolution: "strict-uri-encode@npm:2.0.0"
@ -31690,6 +31829,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tar-stream@npm:3.1.6":
version: 3.1.6
resolution: "tar-stream@npm:3.1.6"
dependencies:
b4a: ^1.6.4
fast-fifo: ^1.2.0
streamx: ^2.15.0
checksum: f3627f918581976e954ff03cb8d370551053796b82564f8c7ca8fac84c48e4d042026d0854fc222171a34ff9c682b72fae91be9c9b0a112d4c54f9e4f443e9c5
languageName: node
linkType: hard
"tar-stream@npm:^2.1.4": "tar-stream@npm:^2.1.4":
version: 2.2.0 version: 2.2.0
resolution: "tar-stream@npm:2.2.0" resolution: "tar-stream@npm:2.2.0"