Desktop: Support converting multiple notes from HTML to Markdown at once (#13802)

This commit is contained in:
Henry Heino
2025-12-01 10:52:01 -08:00
committed by GitHub
parent 5ba8cefe7c
commit f9d58742c0
14 changed files with 145 additions and 135 deletions

View File

@@ -165,8 +165,6 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -207,7 +205,6 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -1245,6 +1242,8 @@ packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/convertNoteToMarkdown.test.js
packages/lib/commands/convertNoteToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js

5
.gitignore vendored
View File

@@ -137,8 +137,6 @@ packages/app-desktop/app.reducer.js
packages/app-desktop/app.js
packages/app-desktop/bridge.js
packages/app-desktop/checkForUpdates.js
packages/app-desktop/commands/convertNoteToMarkdown.test.js
packages/app-desktop/commands/convertNoteToMarkdown.js
packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyToClipboard.js
packages/app-desktop/commands/editProfileConfig.js
@@ -179,7 +177,6 @@ packages/app-desktop/gui/ConfigScreen/controls/ToggleAdvancedSettingsButton.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginBox.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/PluginsStates.js
packages/app-desktop/gui/ConfigScreen/controls/plugins/SearchPlugins.js
packages/app-desktop/gui/ConversionNotification/ConversionNotification.js
packages/app-desktop/gui/Dialog.js
packages/app-desktop/gui/DialogButtonRow.js
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
@@ -1217,6 +1214,8 @@ packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/convertNoteToMarkdown.test.js
packages/lib/commands/convertNoteToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js

View File

@@ -1,52 +0,0 @@
import { _ } from '@joplin/lib/locale';
import Note from '@joplin/lib/models/Note';
import { stateUtils } from '@joplin/lib/reducer';
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { MarkupLanguage } from '@joplin/renderer';
import { runtime as convertHtmlToMarkdown } from '@joplin/lib/commands/convertHtmlToMarkdown';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
name: 'convertNoteToMarkdown',
label: () => _('Convert note to Markdown'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteId: string = null) => {
noteId = noteId || stateUtils.selectedNoteId(context.state);
const note = await Note.load(noteId);
if (!note) return;
try {
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const newNote = await Note.duplicate(note.id);
newNote.body = markdownBody;
newNote.markup_language = MarkupLanguage.Markdown;
await Note.save(newNote);
await Note.delete(note.id, { toTrash: true });
context.dispatch({
type: 'NOTE_HTML_TO_MARKDOWN_DONE',
value: note.id,
});
context.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
} catch (error) {
bridge().showErrorMessageBox(_('Could not convert note to Markdown: %s', error.message));
}
},
enabledCondition: 'oneNoteSelected && noteIsHtml && !noteIsReadOnly',
};
};

View File

@@ -1,5 +1,4 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
import * as copyDevCommand from './copyDevCommand';
import * as copyToClipboard from './copyToClipboard';
import * as editProfileConfig from './editProfileConfig';
@@ -25,7 +24,6 @@ import * as toggleSafeMode from './toggleSafeMode';
import * as toggleTabMovesFocus from './toggleTabMovesFocus';
const index: any[] = [
convertNoteToMarkdown,
copyDevCommand,
copyToClipboard,
editProfileConfig,

View File

@@ -1,28 +0,0 @@
import * as React from 'react';
import { useContext, useEffect } from 'react';
import { _ } from '@joplin/lib/locale';
import { Dispatch } from 'redux';
import { PopupNotificationContext } from '../PopupNotification/PopupNotificationProvider';
import { NotificationType } from '../PopupNotification/types';
interface Props {
noteId: string;
dispatch: Dispatch;
}
export default (props: Props) => {
const popupManager = useContext(PopupNotificationContext);
useEffect(() => {
if (!props.noteId || props.noteId === '') return;
props.dispatch({ type: 'NOTE_HTML_TO_MARKDOWN_DONE', value: '' });
const notification = popupManager.createPopup(() => (
<div>{_('The note has been converted to Markdown and the original note has been moved to the trash')}</div>
), { type: NotificationType.Success });
notification.scheduleDismiss();
}, [props.dispatch, popupManager, props.noteId]);
return <div style={{ display: 'none' }}/>;
};

View File

@@ -38,14 +38,12 @@ import restart from '../services/restart';
import { connect } from 'react-redux';
import { NoteListColumns } from '@joplin/lib/services/plugins/api/noteListType';
import validateColumns from './NoteListHeader/utils/validateColumns';
import ConversionNotification from './ConversionNotification/ConversionNotification';
import TrashNotification from './TrashNotification/TrashNotification';
import UpdateNotification from './UpdateNotification/UpdateNotification';
import NoteEditor from './NoteEditor/NoteEditor';
import PluginNotification from './PluginNotification/PluginNotification';
import { Toast } from '@joplin/lib/services/plugins/api/types';
import PluginService from '@joplin/lib/services/plugins/PluginService';
import { Dispatch } from 'redux';
const ipcRenderer = require('electron').ipcRenderer;
@@ -86,7 +84,6 @@ interface Props {
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
noteHtmlToMarkdownDone: string;
}
interface ShareFolderDialogOptions {
@@ -800,10 +797,6 @@ class MainScreenComponent extends React.Component<Props, State> {
return (
<div style={style}>
<ConversionNotification
noteId={this.props.noteHtmlToMarkdownDone}
dispatch={this.props.dispatch as Dispatch}
/>
<TrashNotification
lastDeletion={this.props.lastDeletion}
lastDeletionNotificationTime={this.props.lastDeletionNotificationTime}
@@ -860,7 +853,6 @@ const mapStateToProps = (state: AppState) => {
showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate,
toast: state.toast,
shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64',
noteHtmlToMarkdownDone: state.noteHtmlToMarkdownDone,
};
};

View File

@@ -1,7 +1,8 @@
import * as React from 'react';
import { createContext, useMemo, useRef, useState } from 'react';
import { createContext, useEffect, useMemo, useRef, useState } from 'react';
import { NotificationType, PopupHandle, PopupControl as PopupManager } from './types';
import { Hour, msleep } from '@joplin/utils/time';
import shim from '@joplin/lib/shim';
export const PopupNotificationContext = createContext<PopupManager|null>(null);
export const VisibleNotificationsContext = createContext<PopupSpec[]>([]);
@@ -112,6 +113,18 @@ const PopupNotificationProvider: React.FC<Props> = props => {
return manager;
}, []);
useEffect(() => {
const defaultShowToast = shim.showToast;
shim.showToast = async (message: string, options) => {
const popup = popupManager.createPopup(() => message, { type: options?.type ?? NotificationType.Info });
popup.scheduleDismiss();
};
return () => {
shim.showToast = defaultShowToast;
};
}, [popupManager]);
return <PopupNotificationContext.Provider value={popupManager}>
<VisibleNotificationsContext.Provider value={popupSpecs}>
{props.children}

View File

@@ -1,3 +1,4 @@
import { ToastType } from '@joplin/lib/shim';
import * as React from 'react';
export type PopupHandle = {
@@ -5,14 +6,13 @@ export type PopupHandle = {
scheduleDismiss(delay?: number): void;
};
export enum NotificationType {
Info = 'info',
Success = 'success',
Error = 'error',
}
export type NotificationContentCallback = ()=> React.ReactNode;
// NotificationType is an alias for ToastType
export type NotificationType = ToastType;
// eslint-disable-next-line no-redeclare -- export const is necessary for creating an alias, this is not a redeclaration.
export const NotificationType = ToastType;
export interface PopupOptions {
type?: NotificationType;
}

View File

@@ -13,6 +13,7 @@ import Setting from '@joplin/lib/models/Setting';
const { clipboard } = require('electron');
import { Dispatch } from 'redux';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { MarkupLanguage } from '@joplin/renderer';
const Menu = bridge().Menu;
const MenuItem = bridge().MenuItem;
@@ -143,6 +144,16 @@ export default class NoteListUtils {
menu.append(new MenuItem({ type: 'separator' }));
const includesHtmlNotes = notes.some(n => n.markup_language === MarkupLanguage.Html);
if (includesHtmlNotes) {
menu.append(
new MenuItem(
menuUtils.commandToStatefulMenuItem('convertNoteToMarkdown', noteIds),
),
);
menu.append(new MenuItem({ type: 'separator' }));
}
if ([9, 10, 11].includes(Setting.value('sync.target'))) {
menu.append(
new MenuItem(
@@ -204,7 +215,6 @@ export default class NoteListUtils {
);
}
const pluginViewInfos = pluginUtils.viewInfosByType(props.plugins, 'menuItem');
for (const info of pluginViewInfos) {

View File

@@ -1,18 +1,20 @@
import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { AppState, createAppDefaultState } from '../app.reducer';
import Note from '@joplin/lib/models/Note';
import { defaultState, State } from '../reducer';
import Note from '../models/Note';
import { MarkupLanguage } from '@joplin/renderer';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import Folder from '@joplin/lib/models/Folder';
import { NoteEntity } from '@joplin/lib/services/database/types';
import { setupDatabaseAndSynchronizer, switchClient } from '../testing/test-utils';
import Folder from '../models/Folder';
import { NoteEntity } from '../services/database/types';
import shim from '../shim';
describe('convertNoteToMarkdown', () => {
let state: AppState = undefined;
let state: State = undefined;
beforeEach(async () => {
state = createAppDefaultState({});
state = defaultState;
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
shim.showToast = jest.fn();
});
it('should set the original note to be trashed', async () => {
@@ -29,13 +31,6 @@ describe('convertNoteToMarkdown', () => {
});
it('should recreate a new note that is a clone of the original', async () => {
let noteConvertedToMarkdownId = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(() => {})
.mockImplementationOnce(action => {
noteConvertedToMarkdownId = action.id;
});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
@@ -49,10 +44,11 @@ describe('convertNoteToMarkdown', () => {
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(noteConvertedToMarkdownId).not.toBe('');
const notes = await Note.previews(folder.id);
expect(notes).toHaveLength(1);
const noteConvertedToMarkdownId = notes[0].id;
const markdownNote = await Note.load(noteConvertedToMarkdownId);
@@ -63,15 +59,6 @@ describe('convertNoteToMarkdown', () => {
});
it('should generate action to trigger notification', async () => {
let originalHtmlNoteId = '';
let actionType = '';
const dispatchFn = jest.fn()
.mockImplementationOnce(action => {
originalHtmlNoteId = action.value;
actionType = action.type;
})
.mockImplementationOnce(() => {});
const folder = await Folder.save({ title: 'test_folder' });
const htmlNoteProperties = {
title: 'test',
@@ -85,12 +72,9 @@ describe('convertNoteToMarkdown', () => {
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: jest.fn() });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(originalHtmlNoteId).toBe(htmlNote.id);
expect(actionType).toBe('NOTE_HTML_TO_MARKDOWN_DONE');
expect(shim.showToast).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,75 @@
import { _, _n } from '../locale';
import Note from '../models/Note';
import { CommandRuntime, CommandDeclaration, CommandContext } from '../services/CommandService';
import { MarkupLanguage } from '@joplin/renderer';
import { runtime as convertHtmlToMarkdown } from './convertHtmlToMarkdown';
import shim, { ToastType } from '../shim';
import { NoteEntity } from '../services/database/types';
import { itemIsReadOnly } from '../models/utils/readOnly';
import { ModelType } from '../BaseModel';
import ItemChange from '../models/ItemChange';
import Setting from '../models/Setting';
import Logger from '@joplin/utils/Logger';
const logger = Logger.create('convertNoteToMarkdown');
export const declaration: CommandDeclaration = {
name: 'convertNoteToMarkdown',
label: () => _('Convert to Markdown'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, noteIds: string|string[] = []) => {
if (typeof noteIds === 'string') {
noteIds = [noteIds];
}
if (noteIds.length === 0) {
noteIds = context.state.selectedNoteIds;
}
const notes: NoteEntity[] = await Note.loadItemsByIdsOrFail(noteIds);
try {
let isFirst = true;
let processedCount = 0;
for (const note of notes) {
if (note.markup_language === MarkupLanguage.Markdown) {
logger.warn('Skipping item: Already Markdown.');
continue;
}
if (await itemIsReadOnly(Note, ModelType.Note, ItemChange.SOURCE_UNSPECIFIED, note.id, Setting.value('sync.userId'), context.state.shareService)) {
throw new Error(_('Cannot convert read-only item: "%s"', note.title));
}
const markdownBody = await convertHtmlToMarkdown().execute(context, note.body);
const newNote = await Note.duplicate(note.id);
newNote.body = markdownBody;
newNote.markup_language = MarkupLanguage.Markdown;
await Note.save(newNote);
await Note.delete(note.id, { toTrash: true });
processedCount ++;
if (isFirst) {
context.dispatch({
type: 'NOTE_SELECT',
id: newNote.id,
});
isFirst = false;
}
}
void shim.showToast(_n(
'The note has been converted to Markdown and the original note has been moved to the trash',
'The notes have been converted to Markdown and the original notes have been moved to the trash',
processedCount,
), { type: ToastType.Success });
} catch (error) {
await shim.showErrorDialog(_('Could not convert notes to Markdown: %s', error.message));
}
},
enabledCondition: 'selectionIncludesHtmlNotes && (multipleNotesSelected || !noteIsReadOnly)',
};
};

View File

@@ -1,5 +1,6 @@
// AUTO-GENERATED using `gulp buildScriptIndexes`
import * as convertHtmlToMarkdown from './convertHtmlToMarkdown';
import * as convertNoteToMarkdown from './convertNoteToMarkdown';
import * as deleteNote from './deleteNote';
import * as historyBackward from './historyBackward';
import * as historyForward from './historyForward';
@@ -14,6 +15,7 @@ import * as toggleEditorPlugin from './toggleEditorPlugin';
const index: any[] = [
convertHtmlToMarkdown,
convertNoteToMarkdown,
deleteNote,
historyBackward,
historyForward,

View File

@@ -8,6 +8,7 @@ import { itemIsReadOnlySync, ItemSlice } from '../../models/utils/readOnly';
import ItemChange from '../../models/ItemChange';
import { getTrashFolderId } from '../trash';
import getActivePluginEditorView from '../plugins/utils/getActivePluginEditorView';
import { MarkupLanguage } from '@joplin/renderer';
export interface WhenClauseContextOptions {
commandFolderId?: string;
@@ -18,6 +19,7 @@ export interface WhenClauseContextOptions {
export interface WhenClauseContext {
allSelectedNotesAreDeleted: boolean;
selectionIncludesHtmlNotes: boolean;
foldersAreDeleted: boolean;
foldersIncludeReadOnly: boolean;
folderIsDeleted: boolean;
@@ -88,6 +90,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
// Selected notes properties
allSelectedNotesAreDeleted: !selectedNotes.find(n => !n.deleted_time),
selectionIncludesHtmlNotes: selectedNotes.some(n => n.markup_language === MarkupLanguage.Html),
// Note history
historyhasBackwardNotes: windowState.backwardHistoryNotes && windowState.backwardHistoryNotes.length > 0,

View File

@@ -54,6 +54,16 @@ export interface ShowMessageBoxOptions {
cancelId?: number;
}
export enum ToastType {
Info = 'info',
Error = 'error',
Success = 'success',
}
export interface ShowToastOptions {
type: ToastType;
}
export enum MobilePlatform {
None = '',
Android = 'android',
@@ -458,6 +468,11 @@ const shim = {
return await shim.showMessageBox(message, { type: MessageBoxType.Confirm }) === 0;
},
showToast: async (message: string, { type = ToastType.Info }: ShowToastOptions = null): Promise<void> => {
// Should usually be overridden by implementers
await shim.showMessageBox(message, { type: type === ToastType.Error ? MessageBoxType.Error : MessageBoxType.Info });
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
writeImageToFile: (_image: any, _format: any, _filePath: string): void => {
throw new Error('Not implemented');