import InteropService from '@joplin/lib/services/interop/InteropService'; import CommandService from '@joplin/lib/services/CommandService'; import shim from '@joplin/lib/shim'; import { ExportOptions, FileSystemItem, Module } from '@joplin/lib/services/interop/types'; import { _ } from '@joplin/lib/locale'; import { PluginStates } from '@joplin/lib/services/plugins/reducer'; const bridge = require('@electron/remote').require('./bridge').default; import Setting from '@joplin/lib/models/Setting'; import Note from '@joplin/lib/models/Note'; const { friendlySafeFilename } = require('@joplin/lib/path-utils'); import time from '@joplin/lib/time'; const md5 = require('md5'); const url = require('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 = Object.assign({}, { path: tempFile, format: 'html', target: FileSystemItem.File, sourceNoteIds: [noteId], customCss: '', }, exportOptions); const service = InteropService.instance(); const result = await service.export(fullExportOptions); console.info('Export HTML result: ', result); return tempFile; } private static async exportNoteTo_(target: string, noteId: string, options: ExportNoteOptions = {}) { let win: any = null; let htmlFile: string = null; const cleanup = () => { if (win) win.destroy(); if (htmlFile) shim.fsDriver().remove(htmlFile); }; try { const exportOptions = { customCss: options.customCss ? options.customCss : '', plugins: options.plugins, }; htmlFile = await this.exportNoteToHtmlFile(noteId, exportOptions); const windowOptions = { show: false, }; 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 { const data = await win.webContents.printToPDF(options); 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. win.webContents.print(options, (success: boolean, reason: string) => { // TODO: This is correct but broken in Electron 4. Need to upgrade to 5+ // It calls the callback right away with "false" even if the document hasn't be print yet. cleanup(); if (!success && reason !== 'cancelled') reject(new Error(`Could not print: ${reason}`)); resolve(null); }); } }, 2000); }); 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}`; } public static async export(_dispatch: Function, module: Module, 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]; 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 (options.sourceNoteIds) exportOptions.sourceNoteIds = options.sourceNoteIds; const service = InteropService.instance(); try { const result = await service.export(exportOptions); console.info('Export result: ', result); } catch (error) { console.error(error); bridge().showErrorMessageBox(_('Could not export notes: %s', error.message)); } void CommandService.instance().execute('hideModalMessage'); } }