import InteropService from '@joplin/lib/services/interop/InteropService'; import { Dispatch } from 'redux'; import CommandService from '@joplin/lib/services/CommandService'; import shim from '@joplin/lib/shim'; import { ExportModuleOutputFormat, ExportOptions, FileSystemItem } from '@joplin/lib/services/interop/types'; import { ExportModule } from '@joplin/lib/services/interop/Module'; import { _ } from '@joplin/lib/locale'; import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import bridge from './services/bridge'; import isNoteLockEnabled from '@joplin/lib/services/noteLock/isNoteLockEnabled'; import { unlockNoteLockSession } from '@joplin/lib/services/noteLock/noteLockPrompts'; import noteLockPrompts from './utils/noteLockPrompts'; import Setting from '@joplin/lib/models/Setting'; import Note from '@joplin/lib/models/Note'; import Folder from '@joplin/lib/models/Folder'; import { friendlySafeFilename } from '@joplin/lib/path-utils'; import time from '@joplin/lib/time'; import { BrowserWindow, BrowserWindowConstructorOptions } from 'electron'; const md5 = require('md5'); import * as url from 'url'; interface ExportNoteOptions { customCss?: string; sourceNoteIds?: string[]; sourceFolderIds?: string[]; printBackground?: boolean; pageSize?: string; landscape?: boolean; includeConflicts?: boolean; plugins?: PluginStates; } export default class InteropServiceHelper { private static async exportNoteToHtmlFile(noteId: string, exportOptions: ExportNoteOptions) { const tempFile = `${Setting.value('tempDir')}/${md5(Date.now() + Math.random())}.html`; const fullExportOptions: ExportOptions = { path: tempFile, format: ExportModuleOutputFormat.Html, target: FileSystemItem.File, sourceNoteIds: [noteId], // The PDF export and printing ask first. decryptLockedNotes: true, customCss: '', ...exportOptions }; const service = InteropService.instance(); const result = await service.export(fullExportOptions); // eslint-disable-next-line no-console console.info('Export HTML result: ', result); return tempFile; } private static async exportNoteTo_(target: string, noteId: string, options: ExportNoteOptions = {}) { let win: BrowserWindow|null = null; let htmlFile: string = null; const cleanup = () => { if (win) win.destroy(); if (htmlFile) void shim.fsDriver().remove(htmlFile); }; try { const exportOptions = { customCss: options.customCss ? options.customCss : '', plugins: options.plugins, shouldEmbedOnlyImages: true, }; htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions); const windowOptions: BrowserWindowConstructorOptions = { // Work around a printing issue: As of Electron 39, if the window is initially hidden, printing crashes the app. // This only seems to be necessary on Linux. show: shim.isLinux(), }; win = bridge().newBrowserWindow(windowOptions); return new Promise((resolve, reject) => { win.webContents.on('did-finish-load', () => { // did-finish-load will trigger when most assets are done loading, probably // images, JavaScript and CSS. However it seems it might trigger *before* // all fonts are loaded, which will break for example Katex rendering. // So we need to add an additional timer to make sure fonts are loaded // as it doesn't seem there's any easy way to figure that out. shim.setTimeout(async () => { if (target === 'pdf') { try { // The below line "opens" all
tags // before printing. This assures that the // contents of the tag are visible in printed // pdfs. // https://github.com/laurent22/joplin/issues/6254. await win.webContents.executeJavaScript('document.querySelectorAll(\'details\').forEach(el=>el.setAttribute(\'open\',\'\'))'); const data = await win.webContents.printToPDF({ ...options, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- options.pageSize is a string (user setting); Electron's PrintToPDFOptions.pageSize is a stricter union pageSize: options.pageSize as any, // Allows users to override the CSS page size. // See https://github.com/laurent22/joplin/issues/13096 preferCSSPageSize: true, // Include accessibility information in the output: generateTaggedPDF: true, }); resolve(data); } catch (error) { reject(error); } finally { cleanup(); } } else { // TODO: it is crashing at this point :( Appears to // be a Chromium bug: // https://github.com/electron/electron/issues/19946 // Maybe can be fixed by doing everything from main // process? i.e. creating a function `print()` that // takes the `htmlFile` variable as input. // // 2021-10-01: This old bug is fixed, and has been // replaced by a brand new bug: // https://github.com/electron/electron/issues/28192 // Still doesn't work but at least it doesn't crash // the app. // // 2024-01-31: Printing with webContents.print still // fails on Linux (even if run in the main process). // As such, we use window.print(), which seems to work. // // 2025-05-03: Windows and MacOS also need the window.print() workaround. // See https://github.com/electron/electron/pull/46937. // // 2025-10-30: window.print() now causes a crash on Linux -- switch back to the // other method. const applyWorkaround = true; if (applyWorkaround) { await win.webContents.executeJavaScript(` // Blocks while the print dialog is open window.print(); `); shim.setTimeout(() => { // To prevent a crash, the window can only be closed after a timeout. // This timeout can't be too small, or else it may still crash (e.g. 100ms // is too short). // // See https://github.com/electron/electron/issues/31635 for details cleanup(); resolve(null); }, 1000); } else { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ExportNoteOptions is broader than Electron's WebContentsPrintOptions; the runtime tolerates the extra fields win.webContents.print(options as any, (success: boolean, reason: string) => { cleanup(); if (!success && reason !== 'cancelled') reject(new Error(`Could not print: ${reason}`)); resolve(null); }); } } }, 2000); }); void win.loadURL(url.format({ pathname: htmlFile, protocol: 'file:', slashes: true, })); }); } catch (error) { cleanup(); throw error; } } public static async exportNoteToPdf(noteId: string, options: ExportNoteOptions = {}) { return this.exportNoteTo_('pdf', noteId, options); } public static async printNote(noteId: string, options: ExportNoteOptions = {}) { return this.exportNoteTo_('printer', noteId, options); } public static async defaultFilename(noteId: string, fileExtension: string) { // Default filename is just the date const date = time.formatMsToLocal(new Date().getTime(), time.dateFormat()); let filename = friendlySafeFilename(`${date}`, 100); if (noteId) { const note = await Note.load(noteId); // In a rare case the passed note will be null, use the id for filename filename = friendlySafeFilename(note ? note.title : noteId, 100); } return `${filename}.${fileExtension}`; } // Decrypting exports write locked notes out as plain text, so the user is asked even with the session unlocked, // which only skips the password. public static async confirmLockedNoteExport(dispatch: Dispatch, noteIds: string[], folderIds: string[] = null) { if (!isNoteLockEnabled()) return 'export'; let hasLockedNotes = false; if (noteIds?.length) { hasLockedNotes = (await Note.byIds(noteIds, { fields: ['is_locked'] })).some(note => !!note.is_locked); } else if (folderIds?.length) { hasLockedNotes = await Note.hasLockedNoteInFolderTrees(folderIds); } else { // Same scope as the export of everything: notebooks not in the trash. hasLockedNotes = await Note.hasLockedNoteInFolderTrees(await Folder.childrenIds('')); } if (!hasLockedNotes) return 'export'; const answer = bridge().showMessageBox(_('Some notes are locked and this export format requires them to be unlocked. Would you like to export these notes unprotected, or export without these notes?'), { buttons: [_('Export unprotected'), _('Export without locked notes'), _('Cancel')], }); if (answer === 1) return 'skipLocked'; if (answer === 0 && await unlockNoteLockSession(noteLockPrompts(dispatch))) return 'export'; return 'cancel'; } public static async export(dispatch: Dispatch, module: ExportModule, options: ExportNoteOptions = null) { if (!options) options = {}; let path = null; if (module.target === 'file') { const noteId = options.sourceNoteIds && options.sourceNoteIds.length ? options.sourceNoteIds[0] : null; path = await bridge().showSaveDialog({ filters: [{ name: module.description, extensions: module.fileExtensions }], defaultPath: await this.defaultFilename(noteId, module.fileExtensions[0]), }); } else { path = await bridge().showOpenDialog({ properties: ['openDirectory', 'createDirectory'], }); } if (!path || (Array.isArray(path) && !path.length)) return; if (Array.isArray(path)) path = path[0]; // Backups keep locked notes encrypted, so only the decrypting formats ask. const isBackup = module.format === ExportModuleOutputFormat.Raw || module.format === ExportModuleOutputFormat.Jex; const lockedNoteChoice = isBackup ? 'export' : await this.confirmLockedNoteExport(dispatch, options.sourceNoteIds, options.sourceFolderIds); if (lockedNoteChoice === 'cancel') return; let sourceNoteIds = options.sourceNoteIds; // A selection left with no note would make a single-file export fail on the file it never wrote. if (sourceNoteIds?.length && lockedNoteChoice === 'skipLocked') { sourceNoteIds = (await Note.byIds(sourceNoteIds, { fields: ['id', 'is_locked'] })).filter(note => !note.is_locked).map(note => note.id); if (!sourceNoteIds.length) return; } void CommandService.instance().execute('showModalMessage', _('Exporting to "%s" as "%s" format. Please wait...', path, module.format)); const exportOptions: ExportOptions = {}; exportOptions.path = path; exportOptions.format = module.format; // exportOptions.modulePath = module.path; if (options.plugins) exportOptions.plugins = options.plugins; exportOptions.customCss = options.customCss; exportOptions.target = module.target; exportOptions.includeConflicts = !!options.includeConflicts; if (options.sourceFolderIds) exportOptions.sourceFolderIds = options.sourceFolderIds; if (sourceNoteIds) exportOptions.sourceNoteIds = sourceNoteIds; exportOptions.skipLockedNotes = lockedNoteChoice === 'skipLocked'; exportOptions.decryptLockedNotes = lockedNoteChoice === 'export'; const service = InteropService.instance(); let keepModalOpen = false; try { const result = await service.export(exportOptions); // eslint-disable-next-line no-console console.info('Export result: ', result); if (result.warnings.length) { keepModalOpen = true; const message = [ _('Completed with warnings:'), '', _('If any files are reported missing, download all relevant attachments to this device and try again.'), '', ...result.warnings, ].join('\n'); void CommandService.instance().execute('showModalMessage', message, true); } } catch (error) { console.error(error); bridge().showErrorMessageBox(_('Could not export notes: %s', error.message)); } if (!keepModalOpen) void CommandService.instance().execute('hideModalMessage'); } }