一些检查失败了
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
657 行
23 KiB
TypeScript
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. §ion-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;
|
|
}
|
|
}
|
|
}
|