diff --git a/packages/app-cli/tests/support/onenote/onenote_desktop.one b/packages/app-cli/tests/support/onenote/onenote_desktop.one new file mode 100644 index 0000000000..46e8422480 Binary files /dev/null and b/packages/app-cli/tests/support/onenote/onenote_desktop.one differ diff --git a/packages/lib/fs-driver-base.ts b/packages/lib/fs-driver-base.ts index e19c711f75..2172c0d1c0 100644 --- a/packages/lib/fs-driver-base.ts +++ b/packages/lib/fs-driver-base.ts @@ -21,11 +21,16 @@ export interface RemoveOptions { recursive?: boolean; } -export interface ZipExtractOptions { +export interface ArchiveExtractOptions { source: string; extractTo: string; } +export interface CabExtractOptions extends ArchiveExtractOptions { + // Only files matching the pattern will be extracted + fileNamePattern: string; +} + export interface ZipEntry { entryName: string; name: string; @@ -268,8 +273,11 @@ export default class FsDriverBase { throw new Error('Not implemented: tarCreate'); } - public async zipExtract(_options: ZipExtractOptions): Promise { + public async zipExtract(_options: ArchiveExtractOptions): Promise { throw new Error('Not implemented: zipExtract'); } + public async cabExtract(_options: CabExtractOptions) { + throw new Error('Not implemented: cabExtract.'); + } } diff --git a/packages/lib/fs-driver-node.ts b/packages/lib/fs-driver-node.ts index c753fb8d92..9a642ce646 100644 --- a/packages/lib/fs-driver-node.ts +++ b/packages/lib/fs-driver-node.ts @@ -1,6 +1,8 @@ import AdmZip = require('adm-zip'); -import FsDriverBase, { Stat, ZipEntry, ZipExtractOptions } from './fs-driver-base'; +import FsDriverBase, { Stat, ZipEntry, ArchiveExtractOptions, CabExtractOptions } from './fs-driver-base'; import time from './time'; +import { execCommand } from '@joplin/utils'; +import { extname } from 'path'; const md5File = require('md5-file'); const fs = require('fs-extra'); @@ -211,9 +213,30 @@ export default class FsDriverNode extends FsDriverBase { await require('tar').create(options, filePaths); } - public async zipExtract(options: ZipExtractOptions): Promise { + public async zipExtract(options: ArchiveExtractOptions): Promise { const zip = new AdmZip(options.source); zip.extractAllTo(options.extractTo, false); return zip.getEntries(); } + + public async cabExtract(options: CabExtractOptions) { + if (process.platform !== 'win32') { + throw new Error('Extracting CAB archives is only supported on Windows.'); + } + + const source = this.resolve(options.source); + const extractTo = this.resolve(options.extractTo); + + if (extname(source).toLowerCase() !== '.cab') { + throw new Error(`Invalid file extension. Expected .CAB. Was ${extname(source)}`); + } + + // See https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/expand + await execCommand([ + 'expand.exe', + source, + `-f:${options.fileNamePattern}`, + extractTo, + ], { quiet: true }); + } } diff --git a/packages/lib/services/interop/InteropService.ts b/packages/lib/services/interop/InteropService.ts index 9d28088c6a..f95e2718c4 100644 --- a/packages/lib/services/interop/InteropService.ts +++ b/packages/lib/services/interop/InteropService.ts @@ -142,7 +142,12 @@ export default class InteropService { makeImportModule({ format: 'zip', - fileExtensions: ['zip'], + fileExtensions: [ + 'zip', + 'one', + // .onepkg is a CAB archive, which Joplin can currently only extract on Windows + ...(shim.isWindows() ? ['onepkg'] : []), + ], sources: [FileSystemItem.File], isNoteArchive: false, // Tells whether the file can contain multiple notes (eg. Enex or Jex format) description: _('OneNote Notebook'), diff --git a/packages/lib/services/interop/InteropService_Importer_OneNote.test.ts b/packages/lib/services/interop/InteropService_Importer_OneNote.test.ts index 823164ad50..a1464e9189 100644 --- a/packages/lib/services/interop/InteropService_Importer_OneNote.test.ts +++ b/packages/lib/services/interop/InteropService_Importer_OneNote.test.ts @@ -257,4 +257,18 @@ describe('InteropService_Importer_OneNote', () => { BaseModel.setIdGenerator(originalIdGenerator); }); + + it('should support directly importing .one files', async () => { + const notes = await importNote(`${supportDir}/onenote/onenote_desktop.one`); + + // For this test, just check that the extracted files exist. + expect(notes.map(note => note.title).sort()).toEqual([ + // The three pages contained within the notebook + 'Another page', + 'Page 3', + 'Test', + // The index page + 'onenote_desktop', + ]); + }); }); diff --git a/packages/lib/services/interop/InteropService_Importer_OneNote.ts b/packages/lib/services/interop/InteropService_Importer_OneNote.ts index 61ed38abe7..5809bd8c71 100644 --- a/packages/lib/services/interop/InteropService_Importer_OneNote.ts +++ b/packages/lib/services/interop/InteropService_Importer_OneNote.ts @@ -4,7 +4,7 @@ import InteropService_Importer_Base from './InteropService_Importer_Base'; import { NoteEntity } from '../database/types'; import { rtrimSlashes } from '../../path-utils'; import InteropService_Importer_Md from './InteropService_Importer_Md'; -import { join, resolve, normalize, sep, dirname, extname } from 'path'; +import { join, resolve, normalize, sep, dirname, extname, basename } from 'path'; import Logger from '@joplin/utils/Logger'; import { uuidgen } from '../../uuid'; import shim from '../../shim'; @@ -41,34 +41,67 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo return normalize(withoutBasePath).split(sep)[0]; } - public async exec(result: ImportExportResult) { + 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') { + logger.info('Copying file...'); + + const outputDirectory = join(targetPath, fileNameNoExtension); + await shim.fsDriver().mkdir(outputDirectory); + + await shim.fsDriver().copy(sourcePath, join(outputDirectory, basename(sourcePath))); + } else if (fileExtension === '.onepkg') { + // Change the file extension so that the archive can be extracted + const archivePath = join(targetPath, `${fileNameNoExtension}.cab`); + await shim.fsDriver().copy(sourcePath, archivePath); + + const extractPath = join(targetPath, fileNameNoExtension); + await shim.fsDriver().mkdir(extractPath); + + await shim.fsDriver().cabExtract({ + source: archivePath, + extractTo: extractPath, + // Only the .one files are used--there's no need to extract + // other files. + fileNamePattern: '*.one', + }); + } 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 unzipTempDirectory = await this.temporaryDirectory_(true); - logger.info('Unzipping files...'); - const files = await shim.fsDriver().zipExtract({ source: sourcePath, extractTo: unzipTempDirectory }); + const files = await this.extractFiles_(sourcePath, unzipTempDirectory); if (files.length === 0) { result.warnings.push('Zip file has no files.'); return result; } - const tempOutputDirectory = await this.temporaryDirectory_(true); - const baseFolder = this.getEntryDirectory(unzipTempDirectory, files[0].entryName); + const baseFolder = this.getEntryDirectory(unzipTempDirectory, files[0].path); const notebookBaseDir = join(unzipTempDirectory, baseFolder, sep); const outputDirectory2 = join(tempOutputDirectory, baseFolder); - const notebookFiles = files.filter(e => e.name !== '.onetoc2' && e.name !== 'OneNote_RecycleBin.onetoc2'); + const notebookFiles = files.filter(e => { + return extname(e.path) !== '.onetoc2' && basename(e.path) !== 'OneNote_RecycleBin.onetoc2'; + }); const { oneNoteConverter } = shim.requireDynamic('@joplin/onenote-converter'); logger.info('Extracting OneNote to HTML'); const skippedFiles = []; for (const notebookFile of notebookFiles) { - const notebookFilePath = join(unzipTempDirectory, notebookFile.entryName); + 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', '.onetoc2'].includes(extname(notebookFilePath).toLowerCase())) { - logger.info('Skipping non-OneNote file:', notebookFile.entryName); - skippedFiles.push(notebookFile.entryName); + logger.info('Skipping non-OneNote file:', notebookFile.path); + skippedFiles.push(notebookFile.path); continue; } @@ -94,14 +127,23 @@ export default class InteropService_Importer_OneNote extends InteropService_Impo ...this.options_, 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 moveSvgToLocalFile(baseFolder: string) { const htmlFiles = await this.getValidHtmlFiles(resolve(baseFolder)); diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index 0cc94172c1..4d8b3922f4 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -210,4 +210,5 @@ IDPSSO nameid attrname dpkg -ATTLIST \ No newline at end of file +onepkg +ATTLIST