文件
mynode/packages/app-desktop/InteropServiceHelper.ts
chen 301194963b
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
feat: Initial clean release of CHM
2026-11-04 11:29:39 +08:00

300 行
12 KiB
TypeScript

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<Buffer | null>((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 <details> 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');
}
}