From 6ce88657194b2cd0d9e4e0de537cf1c270946edf Mon Sep 17 00:00:00 2001
From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com>
Date: Tue, 18 Jul 2023 06:58:06 -0700
Subject: [PATCH] Mobile: Add JEX export (#8428)
---
.eslintignore | 7 +-
.gitignore | 7 +-
.../{ => ConfigScreen}/ConfigScreen.tsx | 143 +++--------------
.../ConfigScreen/ConfigScreenButton.tsx | 38 +++++
.../NoteExportButton.test.tsx | 57 +++++++
.../NoteExportSection/NoteExportButton.tsx | 114 +++++++++++++
.../NoteExportSection/exportAllFolders.ts | 29 ++++
.../ConfigScreen/configScreenStyles.ts | 137 ++++++++++++++++
packages/app-mobile/jest.config.js | 6 +
packages/app-mobile/jest.setup.js | 35 ++++
packages/app-mobile/metro.config.js | 17 +-
packages/app-mobile/package.json | 7 +
packages/app-mobile/root.tsx | 17 +-
packages/app-mobile/utils/fs-driver-rn.ts | 73 +++++++--
.../lib/services/interop/InteropService.ts | 40 +++--
.../interop/InteropService_Exporter_Base.ts | 3 +-
.../interop/InteropService_Exporter_Jex.ts | 4 +-
.../interop/InteropService_Importer_Base.ts | 3 +-
.../interop/InteropService_Importer_Jex.ts | 4 +-
packages/lib/services/interop/Module.ts | 17 +-
packages/lib/services/interop/types.ts | 10 ++
yarn.lock | 150 ++++++++++++++++++
22 files changed, 765 insertions(+), 153 deletions(-)
rename packages/app-mobile/components/screens/{ => ConfigScreen}/ConfigScreen.tsx (86%)
create mode 100644 packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.tsx
create mode 100644 packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.tsx
create mode 100644 packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx
create mode 100644 packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.ts
create mode 100644 packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.ts
create mode 100644 packages/app-mobile/jest.setup.js
diff --git a/.eslintignore b/.eslintignore
index 6c095ee64..943d8f3ad 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -422,7 +422,12 @@ packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.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/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
diff --git a/.gitignore b/.gitignore
index 2778ecc77..546bf729b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -407,7 +407,12 @@ packages/app-mobile/components/biometrics/biometricAuthenticate.js
packages/app-mobile/components/biometrics/sensorInfo.js
packages/app-mobile/components/getResponsiveValue.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/Notes.js
packages/app-mobile/components/screens/UpgradeSyncTargetScreen.js
diff --git a/packages/app-mobile/components/screens/ConfigScreen.tsx b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx
similarity index 86%
rename from packages/app-mobile/components/screens/ConfigScreen.tsx
rename to packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx
index 5046d734a..0ad97c062 100644
--- a/packages/app-mobile/components/screens/ConfigScreen.tsx
+++ b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreen.tsx
@@ -1,29 +1,32 @@
/* eslint-disable @typescript-eslint/explicit-member-accessibility */
import Slider from '@react-native-community/slider';
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 NavService from '@joplin/lib/services/NavService';
import ReportService from '@joplin/lib/services/ReportService';
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 shim from '@joplin/lib/shim';
-import setIgnoreTlsErrors from '../../utils/TlsUtils';
+import setIgnoreTlsErrors from '../../../utils/TlsUtils';
import { reg } from '@joplin/lib/registry';
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 { connect } = require('react-redux');
-import ScreenHeader from '../ScreenHeader';
+import ScreenHeader from '../../ScreenHeader';
const { _ } = require('@joplin/lib/locale');
-const { BaseScreenComponent } = require('../base-screen.js');
-const { Dropdown } = require('../Dropdown.js');
-const { themeStyle } = require('../global-style.js');
+const { BaseScreenComponent } = require('../../base-screen.js');
+const { Dropdown } = require('../../Dropdown');
+const { themeStyle } = require('../../global-style.js');
const shared = require('@joplin/lib/components/shared/config-shared.js');
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
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 {
public static navigationOptions(): any {
@@ -223,94 +226,11 @@ class ConfigScreenComponent extends BaseScreenComponent {
public styles() {
const themeId = this.props.themeId;
- const theme = themeStyle(themeId);
if (this.styles_[themeId]) return this.styles_[themeId];
this.styles_ = {};
- const styles: any = {
- 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);
+ this.styles_[themeId] = configScreenStyles(themeId);
return this.styles_[themeId];
}
@@ -388,28 +308,16 @@ class ConfigScreenComponent extends BaseScreenComponent {
);
}
- renderButton(key: string, title: string, clickHandler: ()=> void, options: any = null) {
- if (!options) options = {};
-
- let descriptionComp = null;
- if (options.description) {
- descriptionComp = (
-
- {options.description}
-
- );
- }
-
+ private renderButton(key: string, title: string, clickHandler: ()=> void, options: any = null) {
return (
-
-
-
-
-
- {options.statusComp}
- {descriptionComp}
-
-
+
);
}
@@ -642,12 +550,13 @@ class ConfigScreenComponent extends BaseScreenComponent {
settingComps.push(this.renderButton('profiles_buttons', _('Manage profiles'), this.manageProfilesButtonPress_));
settingComps.push(this.renderButton('status_button', _('Sync Status'), this.syncStatusButtonPress_));
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.renderHeader('export', _('Export')));
+ settingComps.push();
+
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.') }));
if (this.state.profileExportStatus === 'prompt') {
diff --git a/packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.tsx
new file mode 100644
index 000000000..6e443dd39
--- /dev/null
+++ b/packages/app-mobile/components/screens/ConfigScreen/ConfigScreenButton.tsx
@@ -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 => {
+ let descriptionComp = null;
+ if (props.description) {
+ descriptionComp = (
+
+ {props.description}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {props.statusComponent}
+ {descriptionComp}
+
+
+ );
+};
+export default ConfigScreenButton;
diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.tsx b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.tsx
new file mode 100644
index 000000000..8fe8ba62e
--- /dev/null
+++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.tsx
@@ -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();
+
+ 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();
+ });
+});
diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx
new file mode 100644
index 000000000..ff3510c0d
--- /dev/null
+++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.tsx
@@ -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 => {
+ const [exportStatus, setExportStatus] = useState(ExportStatus.NotStarted);
+ const [exportProgress, setExportProgress] = useState(0);
+ const [warnings, setWarnings] = useState('');
+
+ const startExport = useCallback(async () => {
+ // Don't run multiple exports at the same time.
+ if (exportStatus === ExportStatus.Exporting) {
+ return;
+ }
+
+ setExportStatus(ExportStatus.Exporting);
+ const exportTargetPath = join(await makeExportCacheDirectory(), 'jex-export.jex');
+ logger.info(`Exporting all folders to path ${exportTargetPath}`);
+
+ try {
+ // Initially, undetermined progress
+ setExportProgress(undefined);
+
+ const status = await exportAllFolders(exportTargetPath, (status, progress) => {
+ if (progress !== null) {
+ setExportProgress(progress);
+ } else if (status === ExportProgressState.Closing || status === ExportProgressState.QueuingItems) {
+ // We don't have a numeric progress value and the closing/queuing state may take a while.
+ // Set a special progress value:
+ setExportProgress(undefined);
+ }
+ });
+
+ 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 = (
+
+ );
+ const descriptionText = _('Share a copy of all notes in a file format that can be imported by Joplin on a computer.');
+
+ const startOrCancelExportButton = (
+
+ );
+
+ return startOrCancelExportButton;
+ } else {
+ const warningComponent = (
+
+ {_('Warnings:\n%s', warnings)}
+
+ );
+
+ const exportSummary = (
+
+ {_('Exported successfully!')}
+ {warnings.length > 0 ? warningComponent : null}
+
+ );
+ return exportSummary;
+ }
+};
+
+export default NoteExportButton;
diff --git a/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.ts b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.ts
new file mode 100644
index 000000000..32f9f7f33
--- /dev/null
+++ b/packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/exportAllFolders.ts
@@ -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;
diff --git a/packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.ts b/packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.ts
new file mode 100644
index 000000000..4646657bf
--- /dev/null
+++ b/packages/app-mobile/components/screens/ConfigScreen/configScreenStyles.ts
@@ -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;
diff --git a/packages/app-mobile/jest.config.js b/packages/app-mobile/jest.config.js
index ba1e91f68..1a0f36a34 100644
--- a/packages/app-mobile/jest.config.js
+++ b/packages/app-mobile/jest.config.js
@@ -5,6 +5,7 @@ module.exports = {
'ts',
'tsx',
'js',
+ 'jsx',
],
'transform': {
@@ -14,6 +15,11 @@ module.exports = {
testMatch: ['**/*.test.(ts|tsx)'],
testPathIgnorePatterns: ['/node_modules/'],
+ setupFilesAfterEnv: ['./jest.setup.js'],
+
+ // Do transform most packages in node_modules (transformations correct unrecognized
+ // import syntax)
+ transformIgnorePatterns: ['/node_modules/jest'],
slowTestThreshold: 40,
};
diff --git a/packages/app-mobile/jest.setup.js b/packages/app-mobile/jest.setup.js
new file mode 100644
index 000000000..f28975872
--- /dev/null
+++ b/packages/app-mobile/jest.setup.js
@@ -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 });
+});
diff --git a/packages/app-mobile/metro.config.js b/packages/app-mobile/metro.config.js
index 0c2ac7d56..6b1dcbd46 100644
--- a/packages/app-mobile/metro.config.js
+++ b/packages/app-mobile/metro.config.js
@@ -20,8 +20,23 @@ const localPackages = {
'@joplin/fork-uslug': path.resolve(__dirname, '../fork-uslug/'),
'@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/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 = [];
for (const [, v] of Object.entries(localPackages)) {
watchedFolders.push(v);
@@ -49,7 +64,7 @@ module.exports = {
// included in your reusable module as they would be imported when
// the module is actually used.
//
- localPackages,
+ remappedPackages,
{
get: (target, name) => {
if (target.hasOwnProperty(name)) {
diff --git a/packages/app-mobile/package.json b/packages/app-mobile/package.json
index dac2701c5..5763e63de 100644
--- a/packages/app-mobile/package.json
+++ b/packages/app-mobile/package.json
@@ -37,6 +37,7 @@
"jsc-android": "241213.1.0",
"lodash": "4.17.21",
"md5": "2.3.0",
+ "path-browserify": "1.0.1",
"prop-types": "15.8.1",
"punycode": "2.3.0",
"react": "18.2.0",
@@ -79,6 +80,7 @@
"stream": "0.0.2",
"stream-browserify": "3.0.0",
"string-natural-compare": "3.0.1",
+ "tar-stream": "3.1.6",
"timers": "0.1.1",
"url": "0.11.1"
},
@@ -101,12 +103,15 @@
"@codemirror/view": "6.9.3",
"@joplin/tools": "~2.12",
"@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",
"@types/fs-extra": "11.0.1",
"@types/jest": "29.5.1",
"@types/react": "18.0.24",
"@types/react-native": "0.70.6",
"@types/react-redux": "7.1.25",
+ "@types/tar-stream": "2.2.2",
"babel-jest": "29.2.1",
"babel-plugin-module-resolver": "4.1.0",
"execa": "4.1.0",
@@ -119,6 +124,8 @@
"md5-file": "5.0.0",
"metro-react-native-babel-preset": "0.73.9",
"nodemon": "2.0.22",
+ "react-test-renderer": "18.2.0",
+ "sqlite3": "5.1.6",
"ts-jest": "29.1.0",
"ts-loader": "9.4.4",
"ts-node": "10.9.1",
diff --git a/packages/app-mobile/root.tsx b/packages/app-mobile/root.tsx
index 0852d009c..11852a935 100644
--- a/packages/app-mobile/root.tsx
+++ b/packages/app-mobile/root.tsx
@@ -58,7 +58,7 @@ import JoplinDatabase from '@joplin/lib/JoplinDatabase';
import Database from '@joplin/lib/database';
import NotesScreen from './components/screens/Notes';
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 { LogScreen } = require('./components/screens/log.js');
const { StatusScreen } = require('./components/screens/status.js');
@@ -423,6 +423,20 @@ function decryptionWorker_resourceMetadataButNotBlobDecrypted() {
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
async function initialize(dispatch: Function) {
shimInit();
@@ -439,6 +453,7 @@ async function initialize(dispatch: Function) {
Setting.setConstant('env', __DEV__ ? 'dev' : 'prod');
Setting.setConstant('appId', 'net.cozic.joplin-mobile');
Setting.setConstant('appType', 'mobile');
+ Setting.setConstant('tempDir', await initializeTempDir());
const resourceDir = getResourceDir(currentProfile, isSubProfile);
Setting.setConstant('resourceDir', resourceDir);
diff --git a/packages/app-mobile/utils/fs-driver-rn.ts b/packages/app-mobile/utils/fs-driver-rn.ts
index f87bddea3..39bd06f8c 100644
--- a/packages/app-mobile/utils/fs-driver-rn.ts
+++ b/packages/app-mobile/utils/fs-driver-rn.ts
@@ -1,10 +1,16 @@
import FsDriverBase, { ReadDirStatsOptions } from '@joplin/lib/fs-driver-base';
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;
import { openDocument } from '@joplin/react-native-saf-x';
import RNSAF, { Encoding, DocumentFileDetail, openDocumentTree } from '@joplin/react-native-saf-x';
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://';
@@ -61,17 +67,13 @@ export default class FsDriverRN extends FsDriverBase {
};
}
- public async isDirectory(path: string): Promise {
- return (await this.stat(path)).isDirectory();
- }
-
public async readDirStats(path: string, options: any = null) {
if (!options) options = {};
if (!('recursive' in options)) options.recursive = false;
const isScoped = isScopedUri(path);
- let stats = [];
+ let stats: any[] = [];
try {
if (isScoped) {
stats = await RNSAF.listFiles(path);
@@ -86,12 +88,15 @@ export default class FsDriverRN extends FsDriverBase {
for (let i = 0; i < stats.length; i++) {
const stat = stats[i];
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) {
+ // readUriDirStatsHandleRecursion_ expects stat to have a URI property.
+ // Use the original stat.
output = await this.readUriDirStatsHandleRecursion_(stat, output, options);
} else {
- output = await this.readDirStatsHandleRecursion_(path, stat, output, options);
+ output = await this.readDirStatsHandleRecursion_(path, standardStat, output, options);
}
}
return output;
@@ -135,6 +140,8 @@ export default class FsDriverRN extends FsDriverBase {
await RNSAF.mkdir(path);
return;
}
+
+ // Also creates parent directories: Works like mkdir -p
return RNFS.mkdir(path);
}
@@ -148,8 +155,9 @@ export default class FsDriverRN extends FsDriverBase {
}
return this.rnfsStatToStd_(r, path);
} 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' }
+ // 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.
return null;
} else {
@@ -260,6 +268,53 @@ export default class FsDriverRN extends FsDriverBase {
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 {
let directory;
if (this.isUsingAndroidSAF()) {
diff --git a/packages/lib/services/interop/InteropService.ts b/packages/lib/services/interop/InteropService.ts
index 7163a3f40..5767ae7e6 100644
--- a/packages/lib/services/interop/InteropService.ts
+++ b/packages/lib/services/interop/InteropService.ts
@@ -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 { _ } from '../../locale';
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_frontmatter from './InteropService_Importer_Md_frontmatter';
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_Raw from './InteropService_Exporter_Raw';
import InteropService_Exporter_Md from './InteropService_Exporter_Md';
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_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 { fileExtension } = require('../../path-utils');
const EventEmitter = require('events');
@@ -89,19 +86,18 @@ export default class InteropService {
fileExtensions: ['enex'],
sources: [FileSystemItem.File],
description: _('Evernote Export File (as Markdown)'),
- importerClass: 'InteropService_Importer_EnexToMd',
+ supportsMobile: false,
isDefault: true,
- }, () => new InteropService_Importer_EnexToMd()),
+ }, dynamicRequireModuleFactory('./InteropService_Importer_EnexToMd')),
makeImportModule({
format: 'enex',
fileExtensions: ['enex'],
sources: [FileSystemItem.File],
description: _('Evernote Export File (as HTML)'),
- // TODO: Consider doing this the same way as the multiple `md` importers are handled
- importerClass: 'InteropService_Importer_EnexToHtml',
+ supportsMobile: false,
outputFormat: ImportModuleOutputFormat.Html,
- }, () => new InteropService_Importer_EnexToHtml()),
+ }, dynamicRequireModuleFactory('./InteropService_Importer_EnexToHtml')),
];
const exportModules = [
@@ -136,13 +132,15 @@ export default class InteropService {
target: FileSystemItem.File,
isNoteArchive: false,
description: _('HTML File'),
- }, () => new InteropService_Exporter_Html()),
+ supportsMobile: false,
+ }, dynamicRequireModuleFactory('./InteropService_Exporter_Html')),
makeExportModule({
format: 'html',
target: FileSystemItem.Directory,
description: _('HTML Directory'),
- }, () => new InteropService_Exporter_Html()),
+ supportsMobile: false,
+ }, dynamicRequireModuleFactory('./InteropService_Exporter_Html')),
];
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) {
const modules = this.modules();
const matches = [];
+
+ const isMobile = shim.mobilePlatform() !== '';
for (let i = 0; i < modules.length; i++) {
const m = modules[i];
+
+ if (!m.supportsMobile && isMobile) {
+ continue;
+ }
+
if (m.format === format && m.type === type) {
if (!target && !outputFormat) {
matches.push(m);
@@ -205,7 +210,7 @@ export default class InteropService {
// explicit with which importer we want to use.
//
// 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);
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 itemsToExport: any[] = [];
+ options.onProgress?.(ExportProgressState.QueuingItems, null);
+ let totalItemsToProcess = 0;
+
const queueExportItem = (itemType: number, itemOrId: any) => {
+ totalItemsToProcess ++;
itemsToExport.push({
type: itemType,
itemOrId: itemOrId,
@@ -373,6 +382,7 @@ export default class InteropService {
await exporter.prepareForProcessingItemType(type, itemsToExport);
}
+ let itemsProcessed = 0;
for (let typeOrderIndex = 0; typeOrderIndex < typeOrder.length; typeOrderIndex++) {
const type = typeOrder[typeOrderIndex];
@@ -414,9 +424,13 @@ export default class InteropService {
console.error(error);
result.warnings.push(error.message);
}
+
+ itemsProcessed++;
+ options.onProgress?.(ExportProgressState.Exporting, itemsProcessed / totalItemsToProcess);
}
}
+ options.onProgress?.(ExportProgressState.Closing, null);
await exporter.close();
return result;
diff --git a/packages/lib/services/interop/InteropService_Exporter_Base.ts b/packages/lib/services/interop/InteropService_Exporter_Base.ts
index d82b5b7e0..b35cbd396 100644
--- a/packages/lib/services/interop/InteropService_Exporter_Base.ts
+++ b/packages/lib/services/interop/InteropService_Exporter_Base.ts
@@ -1,6 +1,7 @@
/* eslint @typescript-eslint/no-unused-vars: 0, no-unused-vars: ["error", { "argsIgnorePattern": ".*" }], */
import Setting from '../../models/Setting';
+import shim from '../../shim';
export default class InteropService_Exporter_Base {
private context_: any = {};
@@ -31,7 +32,7 @@ export default class InteropService_Exporter_Base {
protected async temporaryDirectory_(createIt: boolean) {
const md5 = require('md5');
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;
}
}
diff --git a/packages/lib/services/interop/InteropService_Exporter_Jex.ts b/packages/lib/services/interop/InteropService_Exporter_Jex.ts
index 229eda660..5f6966cf8 100644
--- a/packages/lib/services/interop/InteropService_Exporter_Jex.ts
+++ b/packages/lib/services/interop/InteropService_Exporter_Jex.ts
@@ -3,8 +3,6 @@ import InteropService_Exporter_Base from './InteropService_Exporter_Base';
import InteropService_Exporter_Raw from './InteropService_Exporter_Raw';
import shim from '../../shim';
-const fs = require('fs-extra');
-
export default class InteropService_Exporter_Jex extends InteropService_Exporter_Base {
private tempDir_: string;
@@ -41,6 +39,6 @@ export default class InteropService_Exporter_Jex extends InteropService_Exporter
cwd: this.tempDir_,
}, filePaths);
- await fs.remove(this.tempDir_);
+ await shim.fsDriver().remove(this.tempDir_);
}
}
diff --git a/packages/lib/services/interop/InteropService_Importer_Base.ts b/packages/lib/services/interop/InteropService_Importer_Base.ts
index 1f8654362..b27cc228c 100644
--- a/packages/lib/services/interop/InteropService_Importer_Base.ts
+++ b/packages/lib/services/interop/InteropService_Importer_Base.ts
@@ -3,6 +3,7 @@
import { ImportExportResult } from './types';
import Setting from '../../models/Setting';
+import shim from '../../shim';
export default class InteropService_Importer_Base {
@@ -28,7 +29,7 @@ export default class InteropService_Importer_Base {
protected async temporaryDirectory_(createIt: boolean) {
const md5 = require('md5');
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;
}
}
diff --git a/packages/lib/services/interop/InteropService_Importer_Jex.ts b/packages/lib/services/interop/InteropService_Importer_Jex.ts
index ab0a98cc2..e97efb6de 100644
--- a/packages/lib/services/interop/InteropService_Importer_Jex.ts
+++ b/packages/lib/services/interop/InteropService_Importer_Jex.ts
@@ -5,8 +5,6 @@ import InteropService_Importer_Raw from './InteropService_Importer_Raw';
const { filename } = require('../../path-utils');
import shim from '../../shim';
-const fs = require('fs-extra');
-
export default class InteropService_Importer_Jex extends InteropService_Importer_Base {
public async exec(result: ImportExportResult) {
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_);
result = await importer.exec(result);
- await fs.remove(tempDir);
+ await shim.fsDriver().remove(tempDir);
return result;
}
diff --git a/packages/lib/services/interop/Module.ts b/packages/lib/services/interop/Module.ts
index 9db10c021..aea350fbe 100644
--- a/packages/lib/services/interop/Module.ts
+++ b/packages/lib/services/interop/Module.ts
@@ -1,4 +1,5 @@
import { _ } from '../../locale';
+import shim from '../../shim';
import InteropService_Exporter_Base from './InteropService_Exporter_Base';
import InteropService_Importer_Base from './InteropService_Importer_Base';
import { ExportOptions, FileSystemItem, ImportModuleOutputFormat, ImportOptions, ModuleType } from './types';
@@ -10,6 +11,8 @@ interface BaseMetadata {
description: string;
isDefault: boolean;
+ supportsMobile: boolean;
+
// Returns the full label to be displayed in the UI.
fullLabel(moduleSource?: FileSystemItem): string;
@@ -24,7 +27,6 @@ interface ImportMetadata extends BaseMetadata {
type: ModuleType.Importer;
sources: FileSystemItem[];
- importerClass: string;
outputFormat: ImportModuleOutputFormat;
}
@@ -47,6 +49,7 @@ const defaultBaseMetadata = {
fileExtensions: [] as string[],
description: '',
isNoteArchive: true,
+ supportsMobile: true,
isDefault: false,
};
@@ -66,7 +69,6 @@ export const makeImportModule = (
...defaultBaseMetadata,
type: ModuleType.Importer,
sources: [],
- importerClass: '',
outputFormat: ImportModuleOutputFormat.Markdown,
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;
export default Module;
diff --git a/packages/lib/services/interop/types.ts b/packages/lib/services/interop/types.ts
index d91ce96cd..2d9a3ab4a 100644
--- a/packages/lib/services/interop/types.ts
+++ b/packages/lib/services/interop/types.ts
@@ -35,6 +35,14 @@ export interface ImportOptions {
outputFormat?: ImportModuleOutputFormat;
}
+export enum ExportProgressState {
+ QueuingItems,
+ Exporting,
+ Closing,
+}
+
+export type OnExportProgressCallback = (status: ExportProgressState, progress: number)=> void;
+
export interface ExportOptions {
format?: string;
path?: string;
@@ -46,6 +54,8 @@ export interface ExportOptions {
plugins?: PluginStates;
customCss?: string;
packIntoSingleFile?: boolean;
+
+ onProgress?: OnExportProgressCallback;
}
export interface ImportExportResult {
diff --git a/yarn.lock b/yarn.lock
index afd3a7261..febadd449 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4277,6 +4277,15 @@ __metadata:
languageName: node
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":
version: 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/push-notification-ios": 1.11.0
"@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
"@types/fs-extra": 11.0.1
"@types/jest": 29.5.1
"@types/react": 18.0.24
"@types/react-native": 0.70.6
"@types/react-redux": 7.1.25
+ "@types/tar-stream": 2.2.2
assert-browserify: 2.0.0
babel-jest: 29.2.1
babel-plugin-module-resolver: 4.1.0
@@ -4562,6 +4574,7 @@ __metadata:
md5-file: 5.0.0
metro-react-native-babel-preset: 0.73.9
nodemon: 2.0.22
+ path-browserify: 1.0.1
prop-types: 15.8.1
punycode: 2.3.0
react: 18.2.0
@@ -4599,11 +4612,14 @@ __metadata:
react-native-webview: 12.4.0
react-native-zip-archive: 6.0.9
react-redux: 8.0.7
+ react-test-renderer: 18.2.0
redux: 4.2.1
rn-fetch-blob: 0.12.0
+ sqlite3: 5.1.6
stream: 0.0.2
stream-browserify: 3.0.0
string-natural-compare: 3.0.1
+ tar-stream: 3.1.6
timers: 0.1.1
ts-jest: 29.1.0
ts-loader: 9.4.4
@@ -7062,6 +7078,13 @@ __metadata:
languageName: node
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":
version: 4.2.0
resolution: "@sindresorhus/is@npm:4.2.0"
@@ -7212,6 +7235,23 @@ __metadata:
languageName: node
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":
version: 8.0.1
resolution: "@testing-library/react-hooks@npm:8.0.1"
@@ -7234,6 +7274,23 @@ __metadata:
languageName: node
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":
version: 1.1.2
resolution: "@tootallnate/once@npm:1.1.2"
@@ -8086,6 +8143,15 @@ __metadata:
languageName: node
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:*":
version: 4.0.1
resolution: "@types/tough-cookie@npm:4.0.1"
@@ -9996,6 +10062,13 @@ __metadata:
languageName: node
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":
version: 7.0.0-bridge.0
resolution: "babel-core@npm:7.0.0-bridge.0"
@@ -16410,6 +16483,13 @@ __metadata:
languageName: node
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":
version: 2.2.7
resolution: "fast-glob@npm:2.2.7"
@@ -20450,6 +20530,18 @@ __metadata:
languageName: node
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":
version: 29.3.1
resolution: "jest-diff@npm:29.3.1"
@@ -20599,6 +20691,18 @@ __metadata:
languageName: node
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":
version: 29.3.1
resolution: "jest-matcher-utils@npm:29.3.1"
@@ -26222,6 +26326,13 @@ __metadata:
languageName: node
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":
version: 0.0.1
resolution: "path-browserify@npm:0.0.1"
@@ -27023,6 +27134,17 @@ __metadata:
languageName: node
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":
version: 29.5.0
resolution: "pretty-format@npm:29.5.0"
@@ -27442,6 +27564,13 @@ __metadata:
languageName: node
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":
version: 6.0.2
resolution: "queue@npm:6.0.2"
@@ -30918,6 +31047,16 @@ __metadata:
languageName: node
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":
version: 2.0.0
resolution: "strict-uri-encode@npm:2.0.0"
@@ -31690,6 +31829,17 @@ __metadata:
languageName: node
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":
version: 2.2.0
resolution: "tar-stream@npm:2.2.0"