import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService'; import InteropService from '@joplin/lib/services/interop/InteropService'; import { FileSystemItem, ImportModuleOutputFormat, ModuleType } from '@joplin/lib/services/interop/types'; import bridge from '../../../services/bridge'; import { WindowControl } from '../utils/useWindowControl'; import { _ } from '@joplin/lib/locale'; import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl'; import PluginService from '@joplin/lib/services/plugins/PluginService'; import Setting from '@joplin/lib/models/Setting'; import { PackageInfo } from '@joplin/lib/versionInfo'; import shim from '@joplin/lib/shim'; import { ImportModule } from '@joplin/lib/services/interop/Module'; import { isRecoverableError } from '@joplin/lib/import-enex'; import Logger from '@joplin/utils/Logger'; import { Dispatch } from 'redux'; import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types'; import { promptForImportedNoteLockKey } from '@joplin/lib/services/noteLock/noteLockPrompts'; import noteLockPrompts from '../../../utils/noteLockPrompts'; import { ErrorCode } from '@joplin/lib/errors'; const packageInfo: PackageInfo = require('../../../packageInfo.js'); const logger = Logger.create('importFrom'); const makeImportNoteLockKeyHandler = (modalMessage: string, dispatch: Dispatch) => async (backupKey: MasterKeyEntity) => { // The password prompts are DOM dialogs and would render behind the import modal overlay. void CommandService.instance().execute('hideModalMessage'); try { return await promptForImportedNoteLockKey(backupKey, noteLockPrompts(dispatch)); } finally { void CommandService.instance().execute('showModalMessage', modalMessage); } }; export const declaration: CommandDeclaration = { name: 'importFrom', label: () => _('Import...'), }; export interface ImportCommandOptions { sourcePath: string|undefined; sourceType: FileSystemItem; destinationFolderId: string|null; importFormat: string; outputFormat: ImportModuleOutputFormat; } const findImportModule = async (commandOptions: ImportCommandOptions|null, control: WindowControl) => { if (commandOptions) { const module = InteropService.instance().findModuleByFormat( ModuleType.Importer, commandOptions.importFormat, commandOptions.sourceType, commandOptions.outputFormat); if (module) { return module as ImportModule; } } const importModules = InteropService.instance().modules().filter(module => module.type === ModuleType.Importer) as ImportModule[]; return await control.showPrompt({ label: _('Select the type of file to be imported:'), value: '', suggestions: importModules.map(module => { const label = module.fullLabel(); return { key: `${module.type}--${label}`, value: module, label: module.fullLabel(), }; }), }); }; const promptForSourcePath = async (module: ImportModule, sourceType: FileSystemItem|undefined) => { if (!sourceType) { if (!module.sources.includes(FileSystemItem.Directory)) { sourceType = FileSystemItem.File; } if (!module.sources.includes(FileSystemItem.File)) { sourceType = FileSystemItem.Directory; } } if (sourceType === FileSystemItem.File) { return await bridge().showOpenDialog({ filters: [{ name: module.description, extensions: module.fileExtensions }], }); } else if (sourceType === FileSystemItem.Directory) { return await bridge().showOpenDialog({ properties: ['openDirectory', 'createDirectory'], }); } else { return await bridge().showOpenDialog({ properties: ['openDirectory', 'openFile'], }); } }; export const runtime = (control: WindowControl): CommandRuntime => { return { // Since this can be run from "go to anything", partialOptions needs to support being null or empty. execute: async (context: CommandContext, options: ImportCommandOptions|undefined) => { const importModule = await findImportModule(options, control); if (!importModule) return null; // E.g. if cancelled let sourcePath = options?.sourcePath ?? await promptForSourcePath(importModule, options?.sourceType); if (Array.isArray(sourcePath)) { sourcePath = sourcePath[0]; } // Handle the case where the directory picker action was cancelled if (!sourcePath) return null; if (!options) { const isDirectory = await shim.fsDriver().isDirectory(sourcePath); const importsMultipleNotes = importModule.isNoteArchive || isDirectory; const destinationFolderId = importsMultipleNotes ? null : context.state.selectedFolderId; const importFormat = importModule.format; const outputFormat = importModule.outputFormat; options = { sourcePath, destinationFolderId, importFormat, outputFormat, sourceType: isDirectory ? FileSystemItem.Directory : FileSystemItem.File, }; } if (options.importFormat === 'one' && !shim.isWindows()) { const response = bridge().showMessageBox( _('This notebook contains Windows-specific files. For maximum compatibility, consider importing the notebook from Windows.'), { type: 'warning', buttons: [_('Import anyway'), _('Cancel')], defaultId: 0, cancelId: 1, }, ); if (response === 1) return null; } const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', sourcePath, options.importFormat); void CommandService.instance().execute('showModalMessage', modalMessage); const errors: (string|Error)[] = []; const importOptions = { path: sourcePath, format: options.importFormat, outputFormat: options.outputFormat, onProgress: (status: Record) => { const statusStrings: string[] = Object.keys(status).map((key: string) => { return `${key}: ${status[key]}`; }); void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`); }, onError: (error: string|Error) => { // Recovered errors are logged but not reported, since the // notes were imported successfully anyway. if (!isRecoverableError(error)) errors.push(error); console.warn(error); }, destinationFolderId: options.destinationFolderId, onNoteLockKey: makeImportNoteLockKeyHandler(modalMessage, context.dispatch), }; const service = InteropService.instance(); try { const result = await service.import(importOptions); // eslint-disable-next-line no-console console.info('Import result: ', result); } catch (error) { if (error.code !== ErrorCode.Cancelled) { logger.error(error); bridge().showErrorMessageBox(error.message); } } void CommandService.instance().execute('hideModalMessage'); if (errors.length) { const response = bridge().showErrorMessageBox('There were some errors importing the notes - check the console for more details.\n\nPlease consider sending a bug report to the forum!', { buttons: [_('Close'), _('Send bug report')], }); context.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true }); if (response === 1) { const url = makeDiscourseDebugUrl( `Error importing notes from format: ${options.importFormat}`, `- Input format: ${options.importFormat}\n- Output format: ${options.outputFormat}`, errors, packageInfo, PluginService.instance(), Setting.value('plugins.states'), ); void bridge().openExternal(url); } } }, enabledCondition: '', }; };