mirror of
https://github.com/laurent22/joplin.git
synced 2025-01-08 13:06:15 +02:00
Mobile: Support importing from JEX files (#10269)
This commit is contained in:
parent
ce672915da
commit
2ae08ff46e
@ -567,9 +567,12 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebu
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportProfileButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -547,9 +547,12 @@ packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportDebu
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/ExportProfileButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.test.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteExportButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/NoteImportButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/TaskButton.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportAllFolders.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportDebugReport.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/exportProfile.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/NoteExportSection/utils/makeImportExportCacheDirectory.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionHeader.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SectionSelector.js
|
||||
packages/app-mobile/components/screens/ConfigScreen/SettingComponent.js
|
||||
|
@ -18,7 +18,7 @@ import * as shared from '@joplin/lib/components/shared/config/config-shared';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
import biometricAuthenticate from '../../biometrics/biometricAuthenticate';
|
||||
import configScreenStyles, { ConfigScreenStyles } from './configScreenStyles';
|
||||
import NoteExportButton, { exportButtonDescription, exportButtonTitle } from './NoteExportSection/NoteExportButton';
|
||||
import NoteExportButton, { exportButtonDescription, exportButtonDefaultTitle } from './NoteExportSection/NoteExportButton';
|
||||
import SettingsButton from './SettingsButton';
|
||||
import Clipboard from '@react-native-community/clipboard';
|
||||
import { ReactElement, ReactNode } from 'react';
|
||||
@ -31,6 +31,7 @@ import { Button, TextInput } from 'react-native-paper';
|
||||
import PluginService, { PluginSettings } from '@joplin/lib/services/plugins/PluginService';
|
||||
import PluginStates, { getSearchText as getPluginStatesSearchText } from './plugins/PluginStates';
|
||||
import PluginUploadButton, { canInstallPluginsFromFile, buttonLabel as pluginUploadButtonSearchText } from './plugins/PluginUploadButton';
|
||||
import NoteImportButton, { importButtonDefaultTitle, importButtonDescription } from './NoteExportSection/NoteImportButton';
|
||||
|
||||
interface ConfigScreenState {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
|
||||
@ -524,10 +525,14 @@ class ConfigScreenComponent extends BaseScreenComponent<ConfigScreenProps, Confi
|
||||
addSettingButton('fix_search_engine_index', this.state.fixingSearchIndex ? _('Fixing search index...') : _('Fix search index'), this.fixSearchEngineIndexButtonPress_, { disabled: this.state.fixingSearchIndex, description: _('Use this to rebuild the search index if there is a problem with search. It may take a long time depending on the number of notes.') });
|
||||
}
|
||||
|
||||
if (section.name === 'export') {
|
||||
if (section.name === 'importOrExport') {
|
||||
addSettingComponent(
|
||||
<NoteExportButton key='export_as_jex_button' styles={this.styles()} />,
|
||||
[exportButtonTitle(), exportButtonDescription()],
|
||||
[exportButtonDefaultTitle(), exportButtonDescription()],
|
||||
);
|
||||
addSettingComponent(
|
||||
<NoteImportButton key='import_as_jex_button' styles={this.styles()} />,
|
||||
[importButtonDefaultTitle(), importButtonDescription()],
|
||||
);
|
||||
addSettingComponent(
|
||||
<ExportDebugReportButton key='export_report_button' styles={this.styles()}/>,
|
||||
|
@ -1,16 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { Text, Alert, View } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { ProgressBar } from 'react-native-paper';
|
||||
import { FunctionComponent, useCallback, useState } from 'react';
|
||||
import { FunctionComponent } from 'react';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { join } from 'path';
|
||||
import Share from 'react-native-share';
|
||||
import exportAllFolders, { makeExportCacheDirectory } from './utils/exportAllFolders';
|
||||
import exportAllFolders from './utils/exportAllFolders';
|
||||
import { ExportProgressState } from '@joplin/lib/services/interop/types';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import SettingsButton from '../SettingsButton';
|
||||
import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory';
|
||||
import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton';
|
||||
|
||||
const logger = Logger.create('NoteExportButton');
|
||||
|
||||
@ -18,99 +17,67 @@ interface Props {
|
||||
styles: ConfigScreenStyles;
|
||||
}
|
||||
|
||||
enum ExportStatus {
|
||||
NotStarted,
|
||||
Exporting,
|
||||
Exported,
|
||||
}
|
||||
|
||||
export const exportButtonTitle = () => _('Export all notes as JEX');
|
||||
export const exportButtonDefaultTitle = () => _('Export all notes as JEX');
|
||||
export const exportButtonDescription = () => _('Share a copy of all notes in a file format that can be imported by Joplin on a computer.');
|
||||
|
||||
const NoteExportButton: FunctionComponent<Props> = props => {
|
||||
const [exportStatus, setExportStatus] = useState<ExportStatus>(ExportStatus.NotStarted);
|
||||
const [exportProgress, setExportProgress] = useState<number|undefined>(0);
|
||||
const [warnings, setWarnings] = useState<string>('');
|
||||
const getTitle = (taskStatus: TaskStatus) => {
|
||||
if (taskStatus === TaskStatus.InProgress) {
|
||||
return _('Exporting...');
|
||||
} else {
|
||||
return exportButtonDefaultTitle();
|
||||
}
|
||||
};
|
||||
|
||||
const startExport = useCallback(async () => {
|
||||
// Don't run multiple exports at the same time.
|
||||
if (exportStatus === ExportStatus.Exporting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setExportStatus(ExportStatus.Exporting);
|
||||
const exportTargetPath = join(await makeExportCacheDirectory(), 'jex-export.jex');
|
||||
logger.info(`Exporting all folders to path ${exportTargetPath}`);
|
||||
|
||||
try {
|
||||
// Initially, undetermined progress
|
||||
setExportProgress(undefined);
|
||||
|
||||
const status = await exportAllFolders(exportTargetPath, (status, progress) => {
|
||||
if (progress !== null) {
|
||||
setExportProgress(progress);
|
||||
} else if (status === ExportProgressState.Closing || status === ExportProgressState.QueuingItems) {
|
||||
// We don't have a numeric progress value and the closing/queuing state may take a while.
|
||||
// Set a special progress value:
|
||||
setExportProgress(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
setExportStatus(ExportStatus.Exported);
|
||||
setWarnings(status.warnings.join('\n'));
|
||||
const runExportTask = async (
|
||||
onProgress: OnProgressCallback,
|
||||
setAfterCompleteListener: SetAfterCompleteListenerCallback,
|
||||
) => {
|
||||
const exportTargetPath = join(await makeImportExportCacheDirectory(), 'jex-export.jex');
|
||||
logger.info(`Exporting all folders to path ${exportTargetPath}`);
|
||||
|
||||
setAfterCompleteListener(async (success: boolean) => {
|
||||
if (success) {
|
||||
await Share.open({
|
||||
type: 'application/jex',
|
||||
filename: 'export.jex',
|
||||
url: `file://${exportTargetPath}`,
|
||||
failOnCancel: false,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.error('Unable to export:', e);
|
||||
|
||||
// Display a message to the user (e.g. in the case where the user is out of disk space).
|
||||
Alert.alert(_('Error'), _('Unable to export or share data. Reason: %s', e.toString()));
|
||||
setExportStatus(ExportStatus.NotStarted);
|
||||
} finally {
|
||||
await shim.fsDriver().remove(exportTargetPath);
|
||||
}
|
||||
}, [exportStatus]);
|
||||
await shim.fsDriver().remove(exportTargetPath);
|
||||
});
|
||||
|
||||
if (exportStatus === ExportStatus.NotStarted || exportStatus === ExportStatus.Exporting) {
|
||||
const progressComponent = (
|
||||
<ProgressBar
|
||||
visible={exportStatus === ExportStatus.Exporting}
|
||||
indeterminate={exportProgress === undefined}
|
||||
progress={exportProgress}/>
|
||||
);
|
||||
// Initially, undetermined progress
|
||||
onProgress(undefined);
|
||||
|
||||
const startOrCancelExportButton = (
|
||||
<SettingsButton
|
||||
title={exportStatus === ExportStatus.Exporting ? _('Exporting...') : exportButtonTitle()}
|
||||
disabled={exportStatus === ExportStatus.Exporting}
|
||||
description={exportStatus === ExportStatus.NotStarted ? exportButtonDescription() : null}
|
||||
statusComponent={progressComponent}
|
||||
clickHandler={startExport}
|
||||
styles={props.styles}
|
||||
/>
|
||||
);
|
||||
const status = await exportAllFolders(exportTargetPath, (status, progress) => {
|
||||
if (progress !== null) {
|
||||
onProgress(progress);
|
||||
} else if (status === ExportProgressState.Closing || status === ExportProgressState.QueuingItems) {
|
||||
// We don't have a numeric progress value and the closing/queuing state may take a while.
|
||||
// Set a special progress value:
|
||||
onProgress(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
return startOrCancelExportButton;
|
||||
} else {
|
||||
const warningComponent = (
|
||||
<Text style={props.styles.styleSheet.warningText}>
|
||||
{_('Warnings:\n%s', warnings)}
|
||||
</Text>
|
||||
);
|
||||
onProgress(1);
|
||||
|
||||
const exportSummary = (
|
||||
<View style={props.styles.styleSheet.settingContainer}>
|
||||
<Text style={props.styles.styleSheet.descriptionText}>{_('Exported successfully!')}</Text>
|
||||
{warnings.length > 0 ? warningComponent : null}
|
||||
</View>
|
||||
);
|
||||
return exportSummary;
|
||||
}
|
||||
logger.info('Export complete');
|
||||
|
||||
return { warnings: status.warnings, success: true };
|
||||
};
|
||||
|
||||
const NoteExportButton: FunctionComponent<Props> = props => {
|
||||
return (
|
||||
<TaskButton
|
||||
taskName={exportButtonDefaultTitle()}
|
||||
buttonLabel={getTitle}
|
||||
finishedLabel={_('Exported successfully!')}
|
||||
description={exportButtonDescription()}
|
||||
styles={props.styles}
|
||||
onRunTask={runExportTask}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteExportButton;
|
||||
|
@ -0,0 +1,83 @@
|
||||
import * as React from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
import { FunctionComponent } from 'react';
|
||||
import { join } from 'path';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import pickDocument from '../../../../utils/pickDocument';
|
||||
import makeImportExportCacheDirectory from './utils/makeImportExportCacheDirectory';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import TaskButton, { OnProgressCallback, SetAfterCompleteListenerCallback, TaskStatus } from './TaskButton';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const logger = Logger.create('NoteImportButton');
|
||||
|
||||
interface Props {
|
||||
styles: ConfigScreenStyles;
|
||||
}
|
||||
|
||||
// Exported for search filtering
|
||||
export const importButtonDefaultTitle = () => _('Import from JEX');
|
||||
export const importButtonDescription = () => _('Import notes from a JEX (Joplin Export) file.');
|
||||
|
||||
const getTitle = (taskStatus: TaskStatus) => {
|
||||
if (taskStatus === TaskStatus.InProgress) {
|
||||
return _('Importing...');
|
||||
} else {
|
||||
return importButtonDefaultTitle();
|
||||
}
|
||||
};
|
||||
|
||||
const runImportTask = async (
|
||||
_onProgress: OnProgressCallback,
|
||||
setAfterCompleteListener: SetAfterCompleteListenerCallback,
|
||||
) => {
|
||||
const importTargetPath = join(await makeImportExportCacheDirectory(), 'to-import.jex');
|
||||
logger.info('Importing...');
|
||||
|
||||
setAfterCompleteListener(async (_success: boolean) => {
|
||||
await shim.fsDriver().remove(importTargetPath);
|
||||
});
|
||||
|
||||
const importFiles = await pickDocument(false);
|
||||
if (importFiles.length === 0) {
|
||||
logger.info('Canceled.');
|
||||
return { success: false, warnings: [] };
|
||||
}
|
||||
|
||||
const sourceFileUri = importFiles[0].uri;
|
||||
const sourceFilePath = Platform.select({
|
||||
android: sourceFileUri,
|
||||
ios: decodeURI(sourceFileUri),
|
||||
});
|
||||
await shim.fsDriver().copy(sourceFilePath, importTargetPath);
|
||||
|
||||
try {
|
||||
const status = await InteropService.instance().import({
|
||||
path: importTargetPath,
|
||||
format: 'jex',
|
||||
});
|
||||
|
||||
logger.info('Imported successfully');
|
||||
return { success: true, warnings: status.warnings };
|
||||
} catch (error) {
|
||||
logger.error('Import failed with error', error);
|
||||
throw new Error(_('Import failed. Make sure a JEX file was selected.\nDetails: %s', error.toString()));
|
||||
}
|
||||
};
|
||||
|
||||
const NoteImportButton: FunctionComponent<Props> = props => {
|
||||
return (
|
||||
<TaskButton
|
||||
taskName={importButtonDefaultTitle()}
|
||||
description={importButtonDescription()}
|
||||
buttonLabel={getTitle}
|
||||
finishedLabel={_('Imported successfully!')}
|
||||
styles={props.styles}
|
||||
onRunTask={runImportTask}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoteImportButton;
|
@ -0,0 +1,114 @@
|
||||
import * as React from 'react';
|
||||
import { Alert, Text } from 'react-native';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { ProgressBar } from 'react-native-paper';
|
||||
import { FunctionComponent, useCallback, useState } from 'react';
|
||||
import { ConfigScreenStyles } from '../configScreenStyles';
|
||||
import SettingsButton from '../SettingsButton';
|
||||
import Logger from '@joplin/utils/Logger';
|
||||
|
||||
// Undefined = indeterminate progress
|
||||
export type OnProgressCallback = (progressFraction: number|undefined)=> void;
|
||||
export type AfterCompleteListener = (success: boolean)=> Promise<void>;
|
||||
export type SetAfterCompleteListenerCallback = (listener: AfterCompleteListener)=> void;
|
||||
|
||||
const logger = Logger.create('TaskButton');
|
||||
|
||||
interface TaskResult {
|
||||
warnings: string[];
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export enum TaskStatus {
|
||||
NotStarted,
|
||||
InProgress,
|
||||
Done,
|
||||
}
|
||||
|
||||
interface Props {
|
||||
taskName: string;
|
||||
buttonLabel: (status: TaskStatus)=> string;
|
||||
finishedLabel: string;
|
||||
description?: string;
|
||||
styles: ConfigScreenStyles;
|
||||
onRunTask: (
|
||||
setProgress: OnProgressCallback,
|
||||
setAfterCompleteListener: SetAfterCompleteListenerCallback,
|
||||
)=> Promise<TaskResult>;
|
||||
}
|
||||
|
||||
const TaskButton: FunctionComponent<Props> = props => {
|
||||
const [taskStatus, setTaskStatus] = useState<TaskStatus>(TaskStatus.NotStarted);
|
||||
const [progress, setProgress] = useState<number|undefined>(0);
|
||||
const [warnings, setWarnings] = useState<string>('');
|
||||
|
||||
const startTask = useCallback(async () => {
|
||||
// Don't run multiple task instances at the same time.
|
||||
if (taskStatus === TaskStatus.InProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`Starting task: ${props.taskName}`);
|
||||
|
||||
setTaskStatus(TaskStatus.InProgress);
|
||||
let completedSuccessfully = false;
|
||||
let afterCompleteListener: AfterCompleteListener = async () => {};
|
||||
|
||||
try {
|
||||
// Initially, undetermined progress
|
||||
setProgress(undefined);
|
||||
|
||||
const status = await props.onRunTask(setProgress, (afterComplete: AfterCompleteListener) => {
|
||||
afterCompleteListener = afterComplete;
|
||||
});
|
||||
|
||||
setWarnings(status.warnings.join('\n'));
|
||||
if (status.success) {
|
||||
setTaskStatus(TaskStatus.Done);
|
||||
completedSuccessfully = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Task ${props.taskName} failed`, error);
|
||||
Alert.alert(_('Error'), _('Task "%s" failed with error: %s', props.taskName, error.toString()));
|
||||
} finally {
|
||||
if (!completedSuccessfully) {
|
||||
setTaskStatus(TaskStatus.NotStarted);
|
||||
}
|
||||
|
||||
await afterCompleteListener(completedSuccessfully);
|
||||
}
|
||||
}, [props.onRunTask, props.taskName, taskStatus]);
|
||||
|
||||
let statusComponent = (
|
||||
<ProgressBar
|
||||
visible={taskStatus === TaskStatus.InProgress}
|
||||
indeterminate={progress === undefined}
|
||||
progress={progress}
|
||||
/>
|
||||
);
|
||||
if (taskStatus === TaskStatus.Done && warnings.length > 0) {
|
||||
statusComponent = (
|
||||
<Text style={props.styles.styleSheet.warningText}>
|
||||
{_('Completed with warnings:\n%s', warnings)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
let buttonDescription = props.description;
|
||||
if (taskStatus === TaskStatus.Done) {
|
||||
buttonDescription = props.finishedLabel;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsButton
|
||||
title={props.buttonLabel(taskStatus)}
|
||||
disabled={taskStatus === TaskStatus.InProgress}
|
||||
description={buttonDescription}
|
||||
statusComponent={statusComponent}
|
||||
clickHandler={startTask}
|
||||
styles={props.styles}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskButton;
|
@ -1,15 +1,6 @@
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import { ExportModuleOutputFormat, ExportOptions, FileSystemItem, OnExportProgressCallback } from '@joplin/lib/services/interop/types';
|
||||
import shim from '@joplin/lib/shim';
|
||||
|
||||
import { CachesDirectoryPath } from 'react-native-fs';
|
||||
export const makeExportCacheDirectory = async () => {
|
||||
const targetDir = `${CachesDirectoryPath}/exports`;
|
||||
await shim.fsDriver().mkdir(targetDir);
|
||||
|
||||
return targetDir;
|
||||
};
|
||||
|
||||
const exportFolders = async (path: string, onProgress: OnExportProgressCallback) => {
|
||||
const folders = await Folder.all();
|
||||
|
@ -0,0 +1,12 @@
|
||||
|
||||
import shim from '@joplin/lib/shim';
|
||||
import { CachesDirectoryPath } from 'react-native-fs';
|
||||
|
||||
const makeImportExportCacheDirectory = async () => {
|
||||
const targetDir = `${CachesDirectoryPath}/exports`;
|
||||
await shim.fsDriver().mkdir(targetDir);
|
||||
|
||||
return targetDir;
|
||||
};
|
||||
|
||||
export default makeImportExportCacheDirectory;
|
@ -236,7 +236,7 @@ export const settingsSections = createSelector(
|
||||
});
|
||||
} else {
|
||||
output.push(...([
|
||||
'tools', 'export', 'moreInfo',
|
||||
'tools', 'importOrExport', 'moreInfo',
|
||||
].map(name => {
|
||||
return {
|
||||
name,
|
||||
|
@ -2711,7 +2711,7 @@ class Setting extends BaseModel {
|
||||
'server',
|
||||
'keymap',
|
||||
'tools',
|
||||
'export',
|
||||
'importOrExport',
|
||||
'moreInfo',
|
||||
];
|
||||
}
|
||||
@ -2776,7 +2776,7 @@ class Setting extends BaseModel {
|
||||
if (name === 'keymap') return _('Keyboard Shortcuts');
|
||||
if (name === 'joplinCloud') return _('Joplin Cloud');
|
||||
if (name === 'tools') return _('Tools');
|
||||
if (name === 'export') return _('Export');
|
||||
if (name === 'importOrExport') return _('Import and Export');
|
||||
if (name === 'moreInfo') return _('More information');
|
||||
|
||||
if (this.customSections_[name] && this.customSections_[name].label) return this.customSections_[name].label;
|
||||
@ -2804,7 +2804,7 @@ class Setting extends BaseModel {
|
||||
'note': _('Geolocation, spellcheck, editor toolbar, image resize'),
|
||||
'revisionService': _('Toggle note history, keep notes for'),
|
||||
'tools': _('Logs, profiles, sync status'),
|
||||
'export': _('Export your data'),
|
||||
'importOrExport': _('Import or export your data'),
|
||||
'plugins': _('Enable or disable plugins'),
|
||||
'moreInfo': _('Donate, website'),
|
||||
};
|
||||
@ -2846,7 +2846,7 @@ class Setting extends BaseModel {
|
||||
'keymap': 'fa fa-keyboard',
|
||||
'joplinCloud': 'fa fa-cloud',
|
||||
'tools': 'fa fa-toolbox',
|
||||
'export': 'fa fa-file-export',
|
||||
'importOrExport': 'fa fa-file-export',
|
||||
'moreInfo': 'fa fa-info-circle',
|
||||
};
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user