From 8c8a38e7044f4e922bbbe21fe232ef1cb0a3e3f2 Mon Sep 17 00:00:00 2001 From: pedr Date: Wed, 6 Aug 2025 07:02:13 -0300 Subject: [PATCH] Desktop: Resolves #2059: Add option to transform HTML notes into Markdown (#12730) Co-authored-by: Laurent Cozic --- .eslintignore | 5 + .gitignore | 5 + .../commands/convertNoteToMarkdown.test.ts | 96 +++++++++++++++++++ .../commands/convertNoteToMarkdown.ts | 52 ++++++++++ packages/app-desktop/commands/index.ts | 2 + .../ConversionNotification.tsx | 28 ++++++ packages/app-desktop/gui/MainScreen.tsx | 8 ++ packages/app-desktop/gui/MenuBar.tsx | 1 + .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 34 +++++++ .../gui/NoteEditor/styles/index.ts | 4 + .../app-desktop/gui/NoteEditor/utils/types.ts | 2 + packages/app-desktop/gui/menuCommandNames.ts | 1 + .../commands/convertHtmlToMarkdown.test.ts | 30 ++++++ .../lib/commands/convertHtmlToMarkdown.ts | 22 +++++ packages/lib/commands/index.ts | 2 + .../lib/models/settings/builtInMetadata.ts | 11 +++ packages/lib/reducer.ts | 6 ++ 17 files changed, 309 insertions(+) create mode 100644 packages/app-desktop/commands/convertNoteToMarkdown.test.ts create mode 100644 packages/app-desktop/commands/convertNoteToMarkdown.ts create mode 100644 packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx create mode 100644 packages/lib/commands/convertHtmlToMarkdown.test.ts create mode 100644 packages/lib/commands/convertHtmlToMarkdown.ts diff --git a/.eslintignore b/.eslintignore index 29e5508584..8710a4374d 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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 diff --git a/.gitignore b/.gitignore index a55d35f8bb..c9e65105e7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/packages/app-desktop/commands/convertNoteToMarkdown.test.ts b/packages/app-desktop/commands/convertNoteToMarkdown.test.ts new file mode 100644 index 0000000000..a6b7605d9f --- /dev/null +++ b/packages/app-desktop/commands/convertNoteToMarkdown.test.ts @@ -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: '

Hello

', 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: '

Hello

', + 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: '

Hello

', + 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'); + }); + +}); diff --git a/packages/app-desktop/commands/convertNoteToMarkdown.ts b/packages/app-desktop/commands/convertNoteToMarkdown.ts new file mode 100644 index 0000000000..dc754034db --- /dev/null +++ b/packages/app-desktop/commands/convertNoteToMarkdown.ts @@ -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', + }; +}; diff --git a/packages/app-desktop/commands/index.ts b/packages/app-desktop/commands/index.ts index 385f4da0b2..2b9473ab48 100644 --- a/packages/app-desktop/commands/index.ts +++ b/packages/app-desktop/commands/index.ts @@ -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, diff --git a/packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx b/packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx new file mode 100644 index 0000000000..0c21332d61 --- /dev/null +++ b/packages/app-desktop/gui/ConversionNotification/ConversionNotification.tsx @@ -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(() => ( +
{_('The note has been converted to Markdown and the original note has been moved to the trash')}
+ ), { type: NotificationType.Success }); + notification.scheduleDismiss(); + }, [props.dispatch, popupManager, props.noteId]); + + return
; +}; diff --git a/packages/app-desktop/gui/MainScreen.tsx b/packages/app-desktop/gui/MainScreen.tsx index 2358def522..6e8b079322 100644 --- a/packages/app-desktop/gui/MainScreen.tsx +++ b/packages/app-desktop/gui/MainScreen.tsx @@ -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 { return (
+ { showInvalidJoplinCloudCredential: state.settings['sync.target'] === 10 && state.mustAuthenticate, toast: state.toast, shouldSwitchToAppleSiliconVersion: shim.isAppleSilicon() && process.arch !== 'arm64', + noteHtmlToMarkdownDone: state.noteHtmlToMarkdownDone, }; }; diff --git a/packages/app-desktop/gui/MenuBar.tsx b/packages/app-desktop/gui/MenuBar.tsx index 89a91e5962..d0738b8232 100644 --- a/packages/app-desktop/gui/MenuBar.tsx +++ b/packages/app-desktop/gui/MenuBar.tsx @@ -907,6 +907,7 @@ function useMenu(props: Props) { separator(), menuItemDic.setTags, menuItemDic.showShareNoteDialog, + menuItemDic.convertNoteToMarkdown, separator(), menuItemDic.showNoteProperties, menuItemDic.showNoteContentProperties, diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index 7a1fe6906b..7ea04a7b99 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -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) => { + 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) => { + event.preventDefault(); + Setting.setValue('editor.enableHtmlToMarkdownBanner', false); + }; + const onBannerResourceClick = useCallback(async (event: React.MouseEvent) => { 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 ( +
+

+ {_('This note is in HTML format. Convert it to Markdown to edit it more easily.')} +   + {`${_('Convert it')}`} + {' / '} + {_('Don\'t show this message again')} +

+
+ ); + } + return (
+ {renderConvertHtmlToMarkdown()} {renderResourceWatchingNotification()} {renderResourceInSearchResultsNotification()} { syncUserId: state.settings['sync.userId'], shareCacheSetting: state.settings['sync.shareCache'], searchResults: state.searchResults, + enableHtmlToMarkdownBanner: state.settings['editor.enableHtmlToMarkdownBanner'], }; }; diff --git a/packages/app-desktop/gui/NoteEditor/styles/index.ts b/packages/app-desktop/gui/NoteEditor/styles/index.ts index 64331581e4..aa6e496d4c 100644 --- a/packages/app-desktop/gui/NoteEditor/styles/index.ts +++ b/packages/app-desktop/gui/NoteEditor/styles/index.ts @@ -69,6 +69,10 @@ export default function styles(props: NoteEditorProps) { marginTop: 0, marginBottom: 10, }, + resourceWatchBannerAction: { + textDecoration: 'underline', + color: theme.colorWarnUrl, + }, }; }); } diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index 2a880bd4bd..b5c5992d11 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -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 { diff --git a/packages/app-desktop/gui/menuCommandNames.ts b/packages/app-desktop/gui/menuCommandNames.ts index 760b783b8a..ef9ac11a62 100644 --- a/packages/app-desktop/gui/menuCommandNames.ts +++ b/packages/app-desktop/gui/menuCommandNames.ts @@ -79,6 +79,7 @@ export default function() { 'switchProfile3', 'pasteAsText', 'showNoteProperties', + 'convertNoteToMarkdown', 'toggleEditors', ]; } diff --git a/packages/lib/commands/convertHtmlToMarkdown.test.ts b/packages/lib/commands/convertHtmlToMarkdown.test.ts new file mode 100644 index 0000000000..49ded0e7e7 --- /dev/null +++ b/packages/lib/commands/convertHtmlToMarkdown.test.ts @@ -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([ + ['test', '**test**'], + ['Joplin', '[Joplin](https://joplin.org)'], + ['

Title

\n

Subtitle

', '# Title\n\n## Subtitle'], + ['
  • One
  • Two
', '- One\n- Two'], + ['

First paragraph

This is the second paragraph

', 'First paragraph\n\nThis is the second paragraph'], + ['

A paragraph with bold and italic

', '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); + }); + +}); diff --git a/packages/lib/commands/convertHtmlToMarkdown.ts b/packages/lib/commands/convertHtmlToMarkdown.ts new file mode 100644 index 0000000000..caf4c12a19 --- /dev/null +++ b/packages/lib/commands/convertHtmlToMarkdown.ts @@ -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(`
${html}
`, { + baseUrl: '', + anchorNames: [], + convertEmbeddedPdfsToLinks: true, + }); + + return markdown; + }, + }; +}; diff --git a/packages/lib/commands/index.ts b/packages/lib/commands/index.ts index e4c64f2d89..0d85fe3554 100644 --- a/packages/lib/commands/index.ts +++ b/packages/lib/commands/index.ts @@ -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, diff --git a/packages/lib/models/settings/builtInMetadata.ts b/packages/lib/models/settings/builtInMetadata.ts index 16eba8f555..9c71776179 100644 --- a/packages/lib/models/settings/builtInMetadata.ts +++ b/packages/lib/models/settings/builtInMetadata.ts @@ -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, diff --git a/packages/lib/reducer.ts b/packages/lib/reducer.ts index 223ef484e3..7eb5f36465 100644 --- a/packages/lib/reducer.ts +++ b/packages/lib/reducer.ts @@ -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 = defaultState, action: any) => { } break; + case 'NOTE_HTML_TO_MARKDOWN_DONE': + draft.noteHtmlToMarkdownDone = action.value; + break; + case 'ITEMS_TRASHED': draft.lastDeletion = {