1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Desktop: Resolves #2059: Add option to transform HTML notes into Markdown (#12730)

Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
pedr
2025-08-06 07:02:13 -03:00
committed by GitHub
parent 358134038c
commit 8c8a38e704
17 changed files with 309 additions and 0 deletions

View File

@@ -157,6 +157,8 @@ 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
@@ -197,6 +199,7 @@ 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
@@ -1151,6 +1154,8 @@ packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js

5
.gitignore vendored
View File

@@ -130,6 +130,8 @@ 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
@@ -170,6 +172,7 @@ 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
@@ -1124,6 +1127,8 @@ packages/lib/array.js
packages/lib/callbackUrlUtils.test.js
packages/lib/callbackUrlUtils.js
packages/lib/clipperUtils.js
packages/lib/commands/convertHtmlToMarkdown.test.js
packages/lib/commands/convertHtmlToMarkdown.js
packages/lib/commands/deleteNote.js
packages/lib/commands/historyBackward.js
packages/lib/commands/historyForward.js

View File

@@ -0,0 +1,96 @@
import * as convertHtmlToMarkdown from './convertNoteToMarkdown';
import { AppState, createAppDefaultState } from '../app.reducer';
import Note from '@joplin/lib/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';
describe('convertNoteToMarkdown', () => {
let state: AppState = undefined;
beforeEach(async () => {
state = createAppDefaultState({});
await setupDatabaseAndSynchronizer(1);
await switchClient(1);
});
it('should set the original note to be trashed', async () => {
const folder = await Folder.save({ title: 'test_folder' });
const htmlNote = await Note.save({ title: 'test', body: '<p>Hello</p>', parent_id: folder.id, markup_language: MarkupLanguage.Html });
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: () => {} });
const refreshedNote = await Note.load(htmlNote.id);
expect(htmlNote.deleted_time).toBe(0);
expect(refreshedNote.deleted_time).not.toBe(0);
});
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',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(noteConvertedToMarkdownId).not.toBe('');
const markdownNote = await Note.load(noteConvertedToMarkdownId);
const fields: (keyof NoteEntity)[] = ['parent_id', 'title', 'author', 'is_todo', 'todo_completed'];
for (const field of fields) {
expect(htmlNote[field]).toEqual(markdownNote[field]);
}
});
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',
body: '<p>Hello</p>',
parent_id: folder.id,
markup_language: MarkupLanguage.Html,
author: 'test-author',
is_todo: 1,
todo_completed: 1,
};
const htmlNote = await Note.save(htmlNoteProperties);
state.selectedNoteIds = [htmlNote.id];
await convertHtmlToMarkdown.runtime().execute({ state, dispatch: dispatchFn });
expect(dispatchFn).toHaveBeenCalledTimes(2);
expect(originalHtmlNoteId).toBe(htmlNote.id);
expect(actionType).toBe('NOTE_HTML_TO_MARKDOWN_DONE');
});
});

View File

@@ -0,0 +1,52 @@
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,4 +1,5 @@
// 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';
@@ -24,6 +25,7 @@ import * as toggleSafeMode from './toggleSafeMode';
import * as toggleTabMovesFocus from './toggleTabMovesFocus';
const index: any[] = [
convertNoteToMarkdown,
copyDevCommand,
copyToClipboard,
editProfileConfig,

View File

@@ -0,0 +1,28 @@
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,12 +38,14 @@ 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;
@@ -84,6 +86,7 @@ interface Props {
showInvalidJoplinCloudCredential: boolean;
toast: Toast;
shouldSwitchToAppleSiliconVersion: boolean;
noteHtmlToMarkdownDone: string;
}
interface ShareFolderDialogOptions {
@@ -797,6 +800,10 @@ 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}
@@ -853,6 +860,7 @@ 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

@@ -907,6 +907,7 @@ function useMenu(props: Props) {
separator(),
menuItemDic.setTags,
menuItemDic.showShareNoteDialog,
menuItemDic.convertNoteToMarkdown,
separator(),
menuItemDic.showNoteProperties,
menuItemDic.showNoteContentProperties,

View File

@@ -466,6 +466,7 @@ function NoteEditorContent(props: NoteEditorProps) {
// It is currently used to remember pdf scroll position for each attachments of each note uniquely.
noteId: props.noteId,
watchedNoteFiles: props.watchedNoteFiles,
enableHtmlToMarkdownBanner: props.enableHtmlToMarkdownBanner,
};
let editor = null;
@@ -488,6 +489,17 @@ function NoteEditorContent(props: NoteEditorProps) {
setShowRevisions(false);
}, []);
const onBannerConvertItToMarkdown = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
if (!props.selectedNoteIds || props.selectedNoteIds.length === 0) return;
await CommandService.instance().execute('convertNoteToMarkdown', props.selectedNoteIds[0]);
}, [props.selectedNoteIds]);
const onHideBannerConvertItToMarkdown = async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
Setting.setValue('editor.enableHtmlToMarkdownBanner', false);
};
const onBannerResourceClick = useCallback(async (event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const resourceId = event.currentTarget.getAttribute('data-resource-id');
@@ -632,9 +644,30 @@ function NoteEditorContent(props: NoteEditorProps) {
const theme = themeStyle(props.themeId);
function renderConvertHtmlToMarkdown(): React.ReactNode {
if (!props.enableHtmlToMarkdownBanner) return null;
const note = props.notes.find(n => n.id === props.selectedNoteIds[0]);
if (!note) return null;
if (note.markup_language !== MarkupLanguage.Html) return null;
return (
<div style={styles.resourceWatchBanner}>
<p style={styles.resourceWatchBannerLine}>
{_('This note is in HTML format. Convert it to Markdown to edit it more easily.')}
&nbsp;
<a href="#" style={styles.resourceWatchBannerAction} onClick={onBannerConvertItToMarkdown}>{`${_('Convert it')}`}</a>
{' / '}
<a href="#" style={styles.resourceWatchBannerAction} onClick={onHideBannerConvertItToMarkdown}>{_('Don\'t show this message again')}</a>
</p>
</div>
);
}
return (
<div style={styles.root} onDragOver={onDragOver} onDrop={onDrop} ref={containerRef}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{renderConvertHtmlToMarkdown()}
{renderResourceWatchingNotification()}
{renderResourceInSearchResultsNotification()}
<NoteTitleBar
@@ -722,6 +755,7 @@ const mapStateToProps = (state: AppState, ownProps: ConnectProps) => {
syncUserId: state.settings['sync.userId'],
shareCacheSetting: state.settings['sync.shareCache'],
searchResults: state.searchResults,
enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'],
};
};

View File

@@ -69,6 +69,10 @@ export default function styles(props: NoteEditorProps) {
marginTop: 0,
marginBottom: 10,
},
resourceWatchBannerAction: {
textDecoration: 'underline',
color: theme.colorWarnUrl,
},
};
});
}

View File

@@ -67,6 +67,7 @@ export interface NoteEditorProps {
onTitleChange?: (title: string)=> void;
bodyEditor: string;
startupPluginsLoaded: boolean;
enableHtmlToMarkdownBanner: boolean;
}
export interface NoteBodyEditorRef {
@@ -138,6 +139,7 @@ export interface NoteBodyEditorProps {
noteId: string;
useCustomPdfViewer: boolean;
watchedNoteFiles: string[];
enableHtmlToMarkdownBanner: boolean;
}
export interface NoteBodyEditorPropsAndRef extends NoteBodyEditorProps {

View File

@@ -79,6 +79,7 @@ export default function() {
'switchProfile3',
'pasteAsText',
'showNoteProperties',
'convertNoteToMarkdown',
'toggleEditors',
];
}

View File

@@ -0,0 +1,30 @@
import { runtime } from './convertHtmlToMarkdown';
import { CommandContext } from '../services/CommandService';
import { defaultState } from '../reducer';
const command = runtime();
const makeContext = (): CommandContext => {
return {
state: defaultState,
dispatch: ()=>{},
};
};
describe('convertHtmlToMarkdown', () => {
it.each([
['<b>test</b>', '**test**'],
['<a href="https://joplin.org">Joplin</a>', '[Joplin](https://joplin.org)'],
['<h1>Title</h1>\n<h2>Subtitle</h2>', '# Title\n\n## Subtitle'],
['<ul><li>One</li><li>Two</li></ul>', '- One\n- Two'],
['<p>First paragraph</p><p>This is the second paragraph</p>', 'First paragraph\n\nThis is the second paragraph'],
['<p>A paragraph with <strong>bold</strong> and <em>italic</em></p>', 'A paragraph with **bold** and *italic*'],
])('should turn HTML into Markdown', async (html, markdown) => {
const context = makeContext();
const result: string = await command.execute(context, html);
expect(result).toBe(markdown);
});
});

View File

@@ -0,0 +1,22 @@
import HtmlToMd from '../HtmlToMd';
import { CommandContext, CommandRuntime, CommandDeclaration } from '../services/CommandService';
export const declaration: CommandDeclaration = {
name: 'convertHtmlToMarkdown',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext, html: string) => {
const htmlToMdParser = new HtmlToMd();
const markdown = await htmlToMdParser.parse(`<div>${html}</div>`, {
baseUrl: '',
anchorNames: [],
convertEmbeddedPdfsToLinks: true,
});
return markdown;
},
};
};

View File

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

View File

@@ -731,6 +731,17 @@ const builtInMetadata = (Setting: typeof SettingType) => {
storage: SettingStorage.File,
isGlobal: true,
},
'editor.enableHtmlToMarkdownBanner': {
value: true,
advanced: true,
type: SettingItemType.Bool,
public: true,
section: 'note',
appTypes: [AppType.Desktop],
label: () => _('Enable HTML-to-Markdown conversion banner'),
storage: SettingStorage.File,
isGlobal: true,
},
'editor.pastePreserveColors': {
value: false,
type: SettingItemType.Bool,

View File

@@ -173,6 +173,7 @@ export interface State extends WindowState {
editorNoteReloadTimeRequest: number;
allowSelectionInOtherFolders: boolean;
noteHtmlToMarkdownDone: string;
// Extra reducer keys go here:
pluginService: PluginServiceState;
@@ -243,6 +244,7 @@ export const defaultState: State = {
mustAuthenticate: false,
allowSelectionInOtherFolders: false,
editorNoteReloadTimeRequest: 0,
noteHtmlToMarkdownDone: '',
pluginService: pluginServiceDefaultState,
shareService: shareServiceDefaultState,
@@ -1073,6 +1075,10 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
}
break;
case 'NOTE_HTML_TO_MARKDOWN_DONE':
draft.noteHtmlToMarkdownDone = action.value;
break;
case 'ITEMS_TRASHED':
draft.lastDeletion = {