文件
mynode/packages/lib/services/interop/InteropService_Importer_OneNote.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

657 行
23 KiB
TypeScript

import { ImportExportResult, ImportModuleOutputFormat, ImportOptions } from './types';
import InteropService_Importer_Base from './InteropService_Importer_Base';
import { NoteEntity } from '../database/types';
import { rtrimSlashes, toForwardSlashes } from '../../path-utils';
import InteropService_Importer_Md from './InteropService_Importer_Md';
import { join, resolve, normalize, sep, extname, basename, relative, dirname } from 'path';
import Logger from '@joplin/utils/Logger';
import { uuidgen } from '../../uuid';
import shim from '../../shim';
import { unique } from '../../ArrayUtils';
import xpsToPngPowerShellScript from './xpsToPngPowerShellScript';
import Note from '../../models/Note';
// cspell:ignore oxps
const logger = Logger.create('InteropService_Importer_OneNote');
const xpsPrintoutImageExtensions = ['.xps', '.oxps'];
const xpsPrintoutPageNumberAttributes = [
'data-onenote-page-number',
'data-joplin-onenote-page-number',
];
export type SvgXml = {
title: string;
content: string;
};
type PageResolutionResult = { path: string };
type PageIdMap = {
get: (pageId: string|null)=> PageResolutionResult|null;
};
type XpsPrintoutPageConversion = {
pageNumber: number;
outputPath: string;
};
type NativeOneNoteConverter = (notebookPath: string, outputDirectory: string, baseDir: string)=> Promise<void>;
const getOneNoteConverter = (): NativeOneNoteConverter => {
try {
return shim.requireDynamic('@joplin/onenote-converter').oneNoteConverter;
} catch (error) {
// Log the original error for debugging:
logger.warn('Failed to load the onenote importer:', error);
// Throw a more user and maintainer-friendly error:
throw new Error('Failed to load @joplin/onenote-converter. Please check that the onenote-converter package was built correctly and bundled with this version of Joplin.\n\nFor build instructions, see https://github.com/laurent22/joplin/blob/dev/packages/onenote-converter/README.md#building.');
}
};
const setEnableUnresponsiveCheck = (enabled: boolean) => {
if (shim.isElectron()) {
shim.electronBridge().setEnableUnresponsiveCheck(enabled);
}
};
interface NoteMetadata {
created: Date;
order?: number;
updated: Date;
// Saving the title in the metadata allows using special characters not supported by the file
// system in imported note titles (e.g. "/")
title: string;
}
// See onenote-converter README.md for more information
export default class InteropService_Importer_OneNote extends InteropService_Importer_Base {
protected importedNotes: Record<string, NoteEntity> = {};
private domParser: DOMParser = null;
private xmlSerializer: XMLSerializer = null;
public async init(sourcePath: string, options: ImportOptions) {
await super.init(sourcePath, options);
if (!options.domParser || !options.xmlSerializer) {
throw new Error('OneNote importer requires DOMParser and XMLSerializer to be able to extract SVG from HTML.');
}
this.domParser = options.domParser;
this.xmlSerializer = options.xmlSerializer;
}
private getEntryDirectory(unzippedPath: string, entryName: string) {
const withoutBasePath = entryName.replace(unzippedPath, '');
return normalize(withoutBasePath).split(sep)[0];
}
private async extractFiles_(sourcePath: string, targetPath: string) {
const fileExtension = extname(sourcePath).toLowerCase();
const fileNameNoExtension = basename(sourcePath, extname(sourcePath));
if (fileExtension === '.zip') {
logger.info('Unzipping files...');
await shim.fsDriver().zipExtract({ source: sourcePath, extractTo: targetPath });
} else if (fileExtension === '.one' || fileExtension === '.onepkg') {
logger.info('Copying file...');
const outputDirectory = join(targetPath, fileNameNoExtension);
await shim.fsDriver().mkdir(outputDirectory);
await shim.fsDriver().copy(sourcePath, join(outputDirectory, basename(sourcePath)));
} else {
throw new Error(`Unknown file extension: ${fileExtension}`);
}
return await shim.fsDriver().readDirStats(targetPath, { recursive: true });
}
private async execImpl_(result: ImportExportResult, unzipTempDirectory: string, tempOutputDirectory: string) {
const sourcePath = rtrimSlashes(this.sourcePath_);
const files = await this.extractFiles_(sourcePath, unzipTempDirectory);
if (files.length === 0) {
result.warnings.push('Zip file has no files.');
return result;
}
const notebookFiles = files.filter(file =>
['.one', '.onepkg', '.onetoc2'].includes(extname(file.path).toLowerCase()) &&
basename(file.path) !== 'OneNote_RecycleBin.onetoc2',
);
const topLevelEntries = unique(notebookFiles.map(file => this.getEntryDirectory(unzipTempDirectory, file.path)));
let baseFolder = '';
for (const entry of topLevelEntries) {
if (!entry) continue;
const stat = await shim.fsDriver().stat(join(unzipTempDirectory, entry));
if (stat?.isDirectory()) {
if (baseFolder) {
throw new Error(`OneNote zip contains files from multiple top-level directories: ${JSON.stringify(topLevelEntries)}`);
}
baseFolder = entry;
}
}
const notebookBaseDir = !baseFolder ? join(unzipTempDirectory, sep) : join(unzipTempDirectory, baseFolder, sep);
const outputDirectory2 = !baseFolder ? tempOutputDirectory : join(tempOutputDirectory, baseFolder);
const oneNoteConverter = getOneNoteConverter();
logger.info('Extracting OneNote to HTML');
const skippedFiles = [];
for (const notebookFile of notebookFiles) {
const notebookFilePath = join(unzipTempDirectory, notebookFile.path);
// In some cases, the OneNote zip file can include folders and other files
// that shouldn't be imported directly. Skip these:
if (!['.one', '.onepkg'].includes(extname(notebookFilePath).toLowerCase())) {
logger.info('Skipping non-OneNote file:', notebookFile.path);
skippedFiles.push(notebookFile.path);
continue;
}
try {
// HACK: The OneNote importer currently runs in the renderer process on desktop.
// If importing a large file takes a long time, the "unresponsive" dialog can be
// shown. Work around this by temporarily disabling the dialog:
setEnableUnresponsiveCheck(false);
await oneNoteConverter(notebookFilePath, resolve(outputDirectory2), notebookBaseDir);
} catch (error) {
// Forward only the error message. Usually the stack trace points to bytes in the WASM file.
// It's very difficult to use and can cause the error report to be longer than the maximum
// length for auto-creating a forum post:
this.options_.onError?.(error.message ?? error);
console.error(error);
} finally {
setEnableUnresponsiveCheck(true);
}
}
if (skippedFiles.length === notebookFiles.length) {
this.options_.onError?.(new Error(`None of the files appear to be from OneNote. Skipped files include: ${JSON.stringify(skippedFiles)}`));
}
logger.info('Postprocessing imported content...');
const fileToMetadata = await this.postprocessGeneratedHtmlInFolder_(tempOutputDirectory);
logger.info('Importing HTML into Joplin');
const importer = new OneNoteHtmlImporter(fileToMetadata);
importer.setMetadata({ fileExtensions: ['html'] });
await importer.init(outputDirectory2, {
...this.options_,
destinationFolder: null,
destinationFolderId: null,
format: 'html',
outputFormat: ImportModuleOutputFormat.Html,
});
logger.info('Finished');
result = await importer.exec(result);
return result;
}
public async exec(result: ImportExportResult) {
const unzipTempDirectory = await this.temporaryDirectory_(true);
const tempOutputDirectory = await this.temporaryDirectory_(true);
try {
return await this.execImpl_(result, unzipTempDirectory, tempOutputDirectory);
} finally {
await shim.fsDriver().remove(unzipTempDirectory);
await shim.fsDriver().remove(tempOutputDirectory);
}
}
private async buildIdMap_(baseFolder: string): Promise<PageIdMap> {
const htmlFiles = await this.getValidHtmlFiles_(resolve(baseFolder));
const pageIdToPath = new Map<string, string>();
for (const file of htmlFiles) {
const fullPath = join(baseFolder, file.path);
const html: string = await shim.fsDriver().readFile(fullPath);
const metaTagMatch = html.match(/<meta name="X-Original-Page-Id" content="([^"]+)"/i);
if (metaTagMatch) {
const pageId = metaTagMatch[1];
pageIdToPath.set(pageId.toUpperCase(), fullPath);
}
}
return {
get: (id: string|null) => {
// Accepting null input matches the behavior of a JavaScript Map's .get method
// and simplifies handling 'not found' edge cases:
if (!id) return null;
const path = pageIdToPath.get(id.toUpperCase());
if (path) {
return { path };
}
return null;
},
};
}
private async postprocessGeneratedHtmlInFolder_(baseFolder: string) {
const htmlFiles = await this.getValidHtmlFiles_(resolve(baseFolder));
const idMap = await this.buildIdMap_(baseFolder);
const fileToMetadata = new Map<string, NoteMetadata>();
for (const file of htmlFiles) {
const fileLocation = join(baseFolder, file.path);
const originalHtml = await shim.fsDriver().readFile(fileLocation);
const { changed, html, metadata } = await this.postprocessGeneratedHtml_(originalHtml, dirname(fileLocation), idMap);
if (changed) {
await shim.fsDriver().writeFile(fileLocation, html, 'utf-8');
}
fileToMetadata.set(resolve(fileLocation), metadata);
}
return fileToMetadata;
}
// Public to allow testing
public async postprocessGeneratedHtml_(html: string, baseFolder: string, idMap: PageIdMap) {
const pipeline = [
(dom: Document, currentFolder: string) => this.extractSvgsToFiles_(dom, currentFolder),
(dom: Document, currentFolder: string) => this.convertXpsPrintoutsToImages_(dom, currentFolder),
(dom: Document, currentFolder: string) => this.convertExternalLinksToInternalLinks_(dom, currentFolder, idMap),
(dom: Document, _currentFolder: string) => Promise.resolve(this.simplifyHtml_(dom)),
];
// Workaround: HTML read directly from the filesystem can cause parseFromString to hang.
// Force creation of a new string.
// See https://github.com/laurent22/joplin/issues/15132
html = `${html} `.substring(0, html.length);
const dom = this.domParser.parseFromString(html, 'text/html');
const parseMetadata = (dom: Document) => {
const parseTimestampMeta = (selector: string) => {
const element = dom.querySelector<HTMLMetaElement>(selector);
// Not all files processed by the importer have timestamp metadata tags
// (e.g. files that contain lists of pages). Fall back:
if (!element) return new Date();
const timeSeconds = Number(element.content);
if (!isFinite(timeSeconds) || timeSeconds < 0) return new Date();
return new Date(timeSeconds * 1000);
};
const parseOrderMeta = (selector: string) => {
const element = dom.querySelector<HTMLMetaElement>(selector);
if (!element) return undefined;
const orderIndex = Number(element.content);
if (!Number.isInteger(orderIndex) || orderIndex < 0) return undefined;
return -orderIndex;
};
return {
created: parseTimestampMeta('meta[name="X-Created-Time"]'),
order: parseOrderMeta('meta[name="X-OneNote-Order"]'),
updated: parseTimestampMeta('meta[name="X-Updated-Time"]'),
title: dom.title,
};
};
// Parse metadata first, since the pipeline can adjust the HTML:
const metadata = parseMetadata(dom);
let changed = false;
for (const task of pipeline) {
const result = await task(dom, baseFolder);
changed ||= result;
}
if (changed) {
// Don't use xmlSerializer here: It breaks <style> blocks.
html = `<!DOCTYPE HTML>\n${dom.documentElement.outerHTML}`;
}
return {
changed,
html,
metadata,
};
}
private async getValidHtmlFiles_(baseFolder: string) {
const files = await shim.fsDriver().readDirStats(baseFolder, { recursive: true });
const htmlFiles = files.filter(f => !f.isDirectory() && f.path.endsWith('.html'));
return htmlFiles;
}
private async convertExternalLinksToInternalLinks_(dom: Document, baseFolder: string, idMap: PageIdMap) {
const links = dom.querySelectorAll<HTMLAnchorElement>('a[href^="onenote"]');
let changed = false;
for (const link of links) {
if (!link.href.startsWith('onenote:')) continue;
// Remove everything before the first query parameter (e.g. &section-id=).
const separatorIndex = link.href.indexOf('&');
const prefixRemoved = link.href.substring(separatorIndex);
const params = new URLSearchParams(prefixRemoved);
const pageId = params.get('page-id');
const targetPage = idMap.get(pageId);
// The target page might be in a different notebook (imported separately)
if (!targetPage) {
logger.info('Page not found for internal link. Page ID: ', pageId, 'link:', JSON.stringify(link.href));
} else {
changed = true;
link.href = relative(baseFolder, targetPage.path);
}
}
return changed;
}
private simplifyHtml_(dom: Document) {
const removeLeadingSpace = (element: Element) => {
const sibling = element.previousSibling;
if (sibling && sibling.nodeName === '#text' && sibling.textContent.trim() === '') {
sibling.remove();
}
};
const nodesToRemove = [
// <script> blocks that aren't marked with a specific type (e.g. application/tex).
{ selector: 'script:not([type])' },
// ID mappings and other metadata (unused at this stage of the import process)
// Remove leading space to avoid unnecessary blank lines in test snapshots
{ selector: 'meta[name="X-Original-Page-Id"]', preprocess: removeLeadingSpace },
{ selector: 'meta[name="X-OneNote-Order"]', preprocess: removeLeadingSpace },
{ selector: 'meta[name="X-Created-Time"]', preprocess: removeLeadingSpace },
{ selector: 'meta[name="X-Updated-Time"]', preprocess: removeLeadingSpace },
// Empty iframes
{ selector: 'iframe[src=""]' },
];
let changed = false;
for (const { selector, preprocess } of nodesToRemove) {
for (const element of dom.querySelectorAll(selector)) {
preprocess?.(element);
element.remove();
changed = true;
}
}
return changed;
}
private isXpsPrintoutImage_(image: HTMLImageElement) {
const src = image.getAttribute('src') ?? '';
return xpsPrintoutImageExtensions.includes(extname(src).toLowerCase());
}
private safeDecodeFileSrc_(src: string) {
try {
return decodeURIComponent(src);
} catch (error) {
logger.warn('Failed to decode OneNote image path:', src, error);
return src;
}
}
private xpsPrintoutOutputFilename_(sourcePath: string, pageNumber: number) {
const extension = extname(sourcePath);
return `${basename(sourcePath, extension)}.page-${pageNumber}.png`;
}
private xpsPrintoutDisplayedPageNumber_(image: HTMLImageElement) {
const pageNumberAttribute = xpsPrintoutPageNumberAttributes.find(attribute => image.hasAttribute(attribute));
const parsedPageNumber = Number.parseInt(pageNumberAttribute ? image.getAttribute(pageNumberAttribute) : '', 10);
return Number.isFinite(parsedPageNumber) && parsedPageNumber >= 0 ? parsedPageNumber : null;
}
private removeXpsPrintoutPageNumberAttributes_(image: HTMLImageElement) {
for (const attribute of xpsPrintoutPageNumberAttributes) {
image.removeAttribute(attribute);
}
}
private replaceXpsPrintoutImagesWithFallbackLinks_(dom: Document, images: HTMLImageElement[]) {
const sortedImages = [...images]
.sort((a, b) => (this.xpsPrintoutDisplayedPageNumber_(a) ?? 0) - (this.xpsPrintoutDisplayedPageNumber_(b) ?? 0));
const container = dom.createElement('div');
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.gap = '1em';
if (sortedImages[0].style.top) container.style.paddingTop = sortedImages[0].style.top;
if (sortedImages[0].style.left) container.style.paddingLeft = sortedImages[0].style.left;
const links = sortedImages
.map(image => {
const displayedPageNumber = this.xpsPrintoutDisplayedPageNumber_(image);
const link = dom.createElement('a');
link.setAttribute('href', image.getAttribute('src') ?? '');
link.textContent = displayedPageNumber === null
? 'XPS printout: Open original XPS file'
: `XPS printout page ${displayedPageNumber + 1}: Open original XPS file`;
link.style.display = 'block';
return link;
});
container.append(...links);
images[0].replaceWith(container);
for (const image of images.slice(1)) image.remove();
}
private async convertXpsPrintoutPagesToImages_(sourcePath: string, conversions: XpsPrintoutPageConversion[]) {
const pendingConversions: XpsPrintoutPageConversion[] = [];
for (const conversion of conversions) {
if (!await shim.fsDriver().exists(conversion.outputPath)) pendingConversions.push(conversion);
}
if (!pendingConversions.length) return;
const { spawn } = shim.requireDynamic('child_process') as typeof import('child_process');
const pagesJsonPath = join(dirname(pendingConversions[0].outputPath), `${uuidgen(10)}.xps-pages.json`);
await shim.fsDriver().writeFile(pagesJsonPath, JSON.stringify(pendingConversions.map(conversion => ({
PageNumber: conversion.pageNumber,
OutputPath: conversion.outputPath,
}))), 'utf-8');
try {
await new Promise<void>((resolve, reject) => {
const processEnv = {
...process.env,
JOPLIN_XPS_INPUT: sourcePath,
JOPLIN_XPS_PAGES_FILE: pagesJsonPath,
};
const childProcess = spawn('PowerShell.exe', [
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-Sta',
'-Command',
'-',
], {
env: processEnv,
windowsHide: true,
});
let stderr = '';
let stdout = '';
childProcess.stderr.on('data', data => {
stderr += data.toString();
});
childProcess.stdout.on('data', data => {
stdout += data.toString();
});
childProcess.on('error', error => {
reject(error);
});
childProcess.on('close', code => {
if (code === 0) {
resolve();
} else {
const output = (stderr || stdout).trim();
reject(new Error(`PowerShell.exe exited with code ${code}.${output ? ` Output: ${output}` : ''}`));
}
});
childProcess.stdin.on('error', () => {
// PowerShell may exit before reading the script. The process close/error events report the result.
});
childProcess.stdin.end(xpsToPngPowerShellScript);
});
} finally {
await shim.fsDriver().remove(pagesJsonPath);
}
}
private async convertXpsPrintoutsToImages_(dom: Document, baseFolder: string) {
const images = Array.from(dom.querySelectorAll<HTMLImageElement>('img[src]')).filter(image => this.isXpsPrintoutImage_(image));
if (!images.length) return false;
if (!shim.isWindows()) {
this.replaceXpsPrintoutImagesWithFallbackLinks_(dom, images);
return true;
}
const conversions = new Map<string, Map<number, string>>();
let changed = false;
for (const image of images) {
const src = image.getAttribute('src') ?? '';
const displayedPageNumber = this.xpsPrintoutDisplayedPageNumber_(image);
const pageNumber = displayedPageNumber === null ? 1 : displayedPageNumber + 1;
const sourcePath = resolve(baseFolder, this.safeDecodeFileSrc_(src));
const outputPath = join(dirname(sourcePath), this.xpsPrintoutOutputFilename_(sourcePath, pageNumber));
let sourceConversions = conversions.get(sourcePath);
if (!sourceConversions) {
sourceConversions = new Map();
conversions.set(sourcePath, sourceConversions);
}
sourceConversions.set(pageNumber, outputPath);
}
for (const [sourcePath, pageMap] of conversions) {
try {
const pageConversions = Array.from(pageMap.entries()).map(([pageNumber, outputPath]) => ({ pageNumber, outputPath }));
await this.convertXpsPrintoutPagesToImages_(sourcePath, pageConversions);
} catch (error) {
logger.warn('Failed to convert OneNote XPS printout pages:', sourcePath, error);
}
}
for (const image of images) {
const src = image.getAttribute('src') ?? '';
const displayedPageNumber = this.xpsPrintoutDisplayedPageNumber_(image);
const pageNumber = displayedPageNumber === null ? 1 : displayedPageNumber + 1;
const sourcePath = resolve(baseFolder, this.safeDecodeFileSrc_(src));
const convertedPath = conversions.get(sourcePath)?.get(pageNumber);
if (convertedPath && await shim.fsDriver().exists(convertedPath)) {
image.setAttribute('src', toForwardSlashes(relative(baseFolder, convertedPath)));
this.removeXpsPrintoutPageNumberAttributes_(image);
changed = true;
}
}
return changed;
}
private async extractSvgsToFiles_(dom: Document, svgBaseFolder: string) {
const { svgs, changed } = this.extractSvgs(dom);
for (const svg of svgs) {
await shim.fsDriver().writeFile(join(svgBaseFolder, svg.title), svg.content, 'utf8');
}
return changed;
}
// Public to allow testing:
public extractSvgs(dom: Document, titleGenerator: ()=> string = () => uuidgen(10)) {
// get all "top-level" SVGS (ignore nested)
const svgNodeList = dom.querySelectorAll('svg');
if (!svgNodeList || !svgNodeList.length) {
return { svgs: [], changed: false };
}
const svgs: SvgXml[] = [];
for (const svgNode of svgNodeList) {
const img = dom.createElement('img');
if (svgNode.hasAttribute('style')) {
img.setAttribute('style', svgNode.getAttribute('style'));
svgNode.removeAttribute('style');
}
for (const entry of svgNode.classList) {
img.classList.add(entry);
}
if (svgNode.hasAttribute('style')) {
img.setAttribute('style', svgNode.getAttribute('style'));
svgNode.removeAttribute('style');
}
// A11Y: Translate SVG titles to ALT text
// See https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/title
const titleElement = svgNode.querySelector('title');
if (titleElement) {
img.alt = titleElement.textContent;
}
const title = `${titleGenerator()}.svg`;
img.setAttribute('src', `./${title}`);
svgs.push({
title,
content: this.xmlSerializer.serializeToString(svgNode),
});
svgNode.replaceWith(img);
}
return {
svgs,
changed: true,
};
}
}
class OneNoteHtmlImporter extends InteropService_Importer_Md {
public constructor(private noteMetadata_: Map<string, NoteMetadata>) {
super();
}
public override async importFile(filePath: string, parentFolderId: string) {
try {
const resolvedPath = shim.fsDriver().resolve(filePath);
const note = await super.importFile(filePath, parentFolderId);
const metadata = this.noteMetadata_.get(resolvedPath);
if (metadata) {
const updatedNote = {
...note,
user_updated_time: metadata.updated.getTime(),
user_created_time: metadata.created.getTime(),
order: metadata.order ?? note.order,
title: metadata.title || note.title,
};
const noteItem = await Note.save(updatedNote, { isNew: false, autoTimestamp: false });
this.importedNotes[resolvedPath] = noteItem;
return noteItem;
} else {
return note;
}
} catch (error) {
error.message = `On ${filePath}: ${error.message}`;
throw error;
}
}
}