一些检查失败了
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
300 行
12 KiB
TypeScript
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');
|
|
}
|
|
|
|
}
|