Desktop: Plugins: Add an "importFrom" command to allow importing notes and notebooks (#13534)

This commit is contained in:
Henry Heino
2025-11-29 10:53:58 +00:00
committed by GitHub
parent 8ec11bddc2
commit d94d057f1d
13 changed files with 350 additions and 147 deletions
+2
View File
@@ -470,6 +470,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
@@ -512,6 +513,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
+2
View File
@@ -442,6 +442,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/editAlarm.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/exportPdf.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/gotoAnything.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/hideModalMessage.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/importFrom.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/index.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/linkToNote.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/moveToFolder.js
@@ -484,6 +485,7 @@ packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleSideBar.js
packages/app-desktop/gui/WindowCommandsAndDialogs/commands/toggleVisiblePanes.js
packages/app-desktop/gui/WindowCommandsAndDialogs/types.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/appDialogs.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/showFolderPicker.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/usePrintToCallback.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useSyncDialogState.js
packages/app-desktop/gui/WindowCommandsAndDialogs/utils/useWindowCommands.js
+10 -76
View File
@@ -9,7 +9,6 @@ import { PluginStates, utils as pluginUtils } from '@joplin/lib/services/plugins
import shim from '@joplin/lib/shim';
import Setting from '@joplin/lib/models/Setting';
import versionInfo, { PackageInfo } from '@joplin/lib/versionInfo';
import makeDiscourseDebugUrl from '@joplin/lib/makeDiscourseDebugUrl';
import { ImportModule } from '@joplin/lib/services/interop/Module';
import InteropServiceHelper from '../InteropServiceHelper';
import { _ } from '@joplin/lib/locale';
@@ -29,6 +28,8 @@ import { EventName } from '@joplin/lib/eventManager';
import { ipcRenderer } from 'electron';
import NavService from '@joplin/lib/services/NavService';
import Logger from '@joplin/utils/Logger';
import { ImportCommandOptions } from './WindowCommandsAndDialogs/commands/importFrom';
import { FileSystemItem } from '@joplin/lib/services/interop/types';
const logger = Logger.create('MenuBar');
@@ -304,83 +305,16 @@ function useMenu(props: Props) {
void CommandService.instance().execute(commandName);
}, []);
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: string) => {
let path = null;
if (moduleSource === 'file') {
path = await bridge().showOpenDialog({
filters: [{ name: module.description, extensions: module.fileExtensions }],
});
} else {
path = await bridge().showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
});
}
if (!path || (Array.isArray(path) && !path.length)) return;
if (Array.isArray(path)) path = path[0];
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', path, module.format);
void CommandService.instance().execute('showModalMessage', modalMessage);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const errors: any[] = [];
const importOptions = {
path,
format: module.format,
outputFormat: module.outputFormat,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onProgress: (status: any) => {
const statusStrings: string[] = Object.keys(status).map((key: string) => {
return `${key}: ${status[key]}`;
});
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onError: (error: any) => {
errors.push(error);
console.warn(error);
},
const onImportModuleClick = useCallback(async (module: ImportModule, moduleSource: FileSystemItem) => {
const options: ImportCommandOptions = {
destinationFolderId: !module.isNoteArchive && moduleSource === 'file' ? props.selectedFolderId : null,
sourcePath: undefined, // Show a file picker
sourceType: moduleSource,
importFormat: module.format,
outputFormat: module.outputFormat,
};
const service = InteropService.instance();
try {
const result = await service.import(importOptions);
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (error) {
bridge().showErrorMessageBox(error.message);
}
void CommandService.instance().execute('hideModalMessage');
if (errors.length) {
const response = bridge().showErrorMessageBox('There was 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')],
});
props.dispatch({ type: 'NOTE_DEVTOOLS_SET', value: true });
if (response === 1) {
const url = makeDiscourseDebugUrl(
`Error importing notes from format: ${module.format}`,
`- Input format: ${module.format}\n- Output format: ${module.outputFormat}`,
errors,
packageInfo,
PluginService.instance(),
props.pluginSettings,
);
void bridge().openExternal(url);
}
}
// eslint-disable-next-line @seiyab/react-hooks/exhaustive-deps -- Old code before rule was applied
}, [props.selectedFolderId, props.pluginSettings]);
await CommandService.instance().execute('importFrom', options);
}, [props.selectedFolderId]);
const onMenuItemClickRef = useRef(null);
onMenuItemClickRef.current = onMenuItemClick;
@@ -0,0 +1,166 @@
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';
const packageInfo: PackageInfo = require('../../../packageInfo.js');
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,
};
}
const modalMessage = _('Importing from "%s" as "%s" format. Please wait...', sourcePath, options.importFormat);
void CommandService.instance().execute('showModalMessage', modalMessage);
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const errors: any[] = [];
const importOptions = {
path: sourcePath,
format: options.importFormat,
outputFormat: options.outputFormat,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onProgress: (status: any) => {
const statusStrings: string[] = Object.keys(status).map((key: string) => {
return `${key}: ${status[key]}`;
});
void CommandService.instance().execute('showModalMessage', `${modalMessage}\n\n${statusStrings.join('\n')}`);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onError: (error: any) => {
errors.push(error);
console.warn(error);
},
destinationFolderId: options.destinationFolderId,
};
const service = InteropService.instance();
try {
const result = await service.import(importOptions);
// eslint-disable-next-line no-console
console.info('Import result: ', result);
} catch (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: '',
};
};
@@ -7,6 +7,7 @@ import * as editAlarm from './editAlarm';
import * as exportPdf from './exportPdf';
import * as gotoAnything from './gotoAnything';
import * as hideModalMessage from './hideModalMessage';
import * as importFrom from './importFrom';
import * as linkToNote from './linkToNote';
import * as moveToFolder from './moveToFolder';
import * as newFolder from './newFolder';
@@ -55,6 +56,7 @@ const index: any[] = [
exportPdf,
gotoAnything,
hideModalMessage,
importFrom,
linkToNote,
moveToFolder,
newFolder,
@@ -1,11 +1,12 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import Folder from '@joplin/lib/models/Folder';
import Note from '@joplin/lib/models/Note';
import BaseItem from '@joplin/lib/models/BaseItem';
import { ModelType } from '@joplin/lib/BaseModel';
import Logger from '@joplin/utils/Logger';
import shim from '@joplin/lib/shim';
import showFolderPicker from '../utils/showFolderPicker';
const logger = Logger.create('commands/moveToFolder');
@@ -31,71 +32,37 @@ export const runtime = (comp: any): CommandRuntime => {
}
}
const folders = await Folder.sortFolderTree();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const startFolders: any[] = [];
const maxDepth = 15;
// It's okay for folders (but not notes) to have no parent folder:
if (allAreFolders) {
startFolders.push({
key: '',
value: '',
label: _('None'),
indentDepth: 0,
});
}
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
// Disallow making a folder a subfolder of itself.
if (itemIdToType.has(folder.id)) {
continue;
}
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
}
};
addOptions(folders, 0);
comp.setState({
promptOptions: {
label: _('Move to notebook:'),
inputType: 'dropdown',
value: '',
autocomplete: startFolders,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
onClose: async (answer: any) => {
if (answer) {
try {
const targetFolderId = answer.value;
for (const id of itemIds) {
if (id === targetFolderId) {
continue;
}
const itemType = itemIdToType.get(id);
if (itemType === ModelType.Note) {
await Note.moveToFolder(id, targetFolderId);
} else if (itemType === ModelType.Folder) {
await Folder.moveToFolder(id, targetFolderId);
} else {
throw new Error(`Cannot move item with type ${itemType}`);
}
}
} catch (error) {
logger.error('Error moving items', error);
void shim.showMessageBox(`Error: ${error}`);
}
}
comp.setState({ promptOptions: null });
},
},
const targetFolderId = await showFolderPicker(comp, {
label: _('Move to notebook:'),
// It's okay for folders (but not notes) to have no parent folder:
allowSelectNone: allAreFolders,
// Don't allow setting a folder as its own parent
showFolder: (folder) => !itemIdToType.has(folder.id),
});
// It's important to allow the case where targetFolderId is the empty string,
// since that corresponds to the toplevel notebook.
if (targetFolderId !== null) {
try {
for (const id of itemIds) {
if (id === targetFolderId) {
continue;
}
const itemType = itemIdToType.get(id);
if (itemType === ModelType.Note) {
await Note.moveToFolder(id, targetFolderId);
} else if (itemType === ModelType.Folder) {
await Folder.moveToFolder(id, targetFolderId);
} else {
throw new Error(`Cannot move item with type ${itemType}`);
}
}
} catch (error) {
logger.error('Error moving items', error);
void shim.showMessageBox(`Error: ${error}`);
}
}
},
enabledCondition: 'someNotesSelected && !noteIsReadOnly',
};
@@ -26,6 +26,7 @@ export interface DialogState {
description?: string;
label?: string;
value?: string;
autocomplete?: unknown;
onClose?: (answer: unknown, buttonType: unknown)=> void;
}|null;
}
@@ -0,0 +1,56 @@
import Folder, { FolderEntityWithChildren } from '@joplin/lib/models/Folder';
import { WindowControl } from './useWindowControl';
import { _ } from '@joplin/lib/locale';
import { FolderEntity } from '@joplin/lib/services/database/types';
interface FolderEntry {
key: string;
value: string;
label: string;
indentDepth: number;
}
interface Options {
label: string;
allowSelectNone: boolean;
showFolder: (entity: FolderEntity)=> boolean;
}
const showFolderPicker = async (control: WindowControl, { label, allowSelectNone, showFolder }: Options) => {
const folders = await Folder.sortFolderTree();
const startFolders: FolderEntry[] = [];
const maxDepth = 15;
if (allowSelectNone) {
startFolders.push({
key: '',
value: '',
label: _('None'),
indentDepth: 0,
});
}
const addOptions = (folders: FolderEntityWithChildren[], depth: number) => {
for (let i = 0; i < folders.length; i++) {
const folder = folders[i];
if (!showFolder(folder)) {
continue;
}
startFolders.push({ key: folder.id, value: folder.id, label: folder.title, indentDepth: depth });
if (folder.children) addOptions(folder.children, (depth + 1) < maxDepth ? depth + 1 : maxDepth);
}
};
addOptions(folders, 0);
const folderId = await control.showPrompt({
label,
value: '',
suggestions: startFolders,
});
return folderId;
};
export default showFolderPicker;
@@ -5,8 +5,22 @@ import { PrintCallback } from './usePrintToCallback';
import { _ } from '@joplin/lib/locale';
import announceForAccessibility from '../../utils/announceForAccessibility';
interface PromptSuggestion<T> {
key: string;
value: T;
label: string;
indentDepth?: number;
}
interface PromptOptions<T> {
label: string;
value: string;
suggestions: PromptSuggestion<T>[];
}
export interface WindowControl {
setState: (update: Partial<DialogState>)=> void;
showPrompt: <T>(options: PromptOptions<T>)=> Promise<T>;
printTo: PrintCallback;
announcePanelVisibility(panelName: string, visible: boolean): void;
}
@@ -19,7 +33,7 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
onPrintRef.current = onPrint;
return useMemo((): WindowControl => {
return {
const control: WindowControl = {
setState: (newPartialState: Partial<DialogState>) => {
setDialogState(oldState => ({
...oldState,
@@ -32,7 +46,29 @@ const useWindowControl = (setDialogState: OnSetDialogState, onPrint: PrintCallba
visible ? _('Panel "%s" is visible', panelName) : _('Panel %s is hidden', panelName),
);
},
showPrompt: <T> (options: PromptOptions<T>) => {
return new Promise<T>((resolve) => {
control.setState({
promptOptions: {
label: options.label,
inputType: 'dropdown',
value: options.value,
autocomplete: options.suggestions,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Partially refactored code before rule was applied
onClose: async (answer: any) => {
if (answer) {
resolve(answer.value);
} else {
resolve(null);
}
control.setState({ promptOptions: null });
},
},
});
});
},
};
return control;
}, [setDialogState]);
};
@@ -212,5 +212,28 @@ test.describe('main', () => {
await electronApp.close();
});
test('should import an HTML directory', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.importHtmlDirectory(electronApp, join(__dirname, 'resources', 'html-import'));
const importedFolder = mainScreen.sidebar.container.getByText('html-import');
await importedFolder.click();
const importedNote1 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await expect(importedNote1).toBeAttached();
const importedNote2 = await mainScreen.noteList.getNoteItemByTitle('test-html-file-2');
await expect(importedNote2).toBeAttached();
});
test('should import a single HTML file', async ({ mainWindow, electronApp }) => {
const mainScreen = await new MainScreen(mainWindow).setup();
await mainScreen.waitFor();
await mainScreen.importHtmlFile(electronApp, join(__dirname, 'resources', 'html-import', 'test-html-file-with-image.html'));
const importedNote = await mainScreen.noteList.getNoteItemByTitle('test-html-file-with-image');
await expect(importedNote).toBeAttached();
});
});
@@ -68,9 +68,17 @@ export default class MainScreen {
await searchBar.fill(text);
}
public async importHtmlDirectory(electronApp: ElectronApplication, path: string) {
private async importFromModule_(electronApp: ElectronApplication, moduleName: string, path: string) {
await setFilePickerResponse(electronApp, [path]);
await activateMainMenuItem(electronApp, 'HTML - HTML document (Directory)', 'Import');
await activateMainMenuItem(electronApp, moduleName, 'Import');
}
public async importHtmlDirectory(electronApp: ElectronApplication, path: string) {
return this.importFromModule_(electronApp, 'HTML - HTML document (Directory)', path);
}
public async importHtmlFile(electronApp: ElectronApplication, path: string) {
return this.importFromModule_(electronApp, 'HTML - HTML document (File)', path);
}
public async pluginPanelLocator(pluginId: string) {
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<html>
<body>
<h1>Test HTML file 2!</h1>
</body>
</html>
@@ -229,7 +229,7 @@ export default class InteropService {
// or exporters, such as ENEX. In this case, the one marked as "isDefault"
// is returned. This is useful to auto-detect the module based on the format.
// For more precise matching, newModuleFromPath_ should be used.
private findModuleByFormat_(type: ModuleType, format: string, target: FileSystemItem = null, outputFormat: ImportModuleOutputFormat = null) {
public findModuleByFormat(type: ModuleType, format: string, target: FileSystemItem = null, outputFormat: ImportModuleOutputFormat = null) {
const modules = this.modules();
const matches = [];
@@ -268,7 +268,7 @@ export default class InteropService {
// https://github.com/laurent22/joplin/pull/1795#discussion_r322379121) but
// we can do it if it ever becomes necessary.
private newModuleByFormat_(type: ModuleType, format: string, outputFormat: ImportModuleOutputFormat = ImportModuleOutputFormat.Markdown) {
const moduleMetadata = this.findModuleByFormat_(type, format, null, outputFormat);
const moduleMetadata = this.findModuleByFormat(type, format, null, outputFormat);
if (!moduleMetadata) throw new Error(_('Cannot load "%s" module for format "%s" and output "%s"', type, format, outputFormat));
return moduleMetadata.factory();
@@ -281,7 +281,7 @@ export default class InteropService {
//
// https://github.com/laurent22/joplin/pull/1795#pullrequestreview-281574417
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));
return moduleMetadata.factory(options);