diff --git a/.eslintignore b/.eslintignore index 659dce6d4e..e273bd91ba 100644 --- a/.eslintignore +++ b/.eslintignore @@ -734,6 +734,7 @@ packages/lib/models/utils/itemCanBeEncrypted.js packages/lib/models/utils/paginatedFeed.js packages/lib/models/utils/paginationToSql.js packages/lib/models/utils/readOnly.js +packages/lib/models/utils/resourceUtils.js packages/lib/models/utils/types.js packages/lib/models/utils/userData.test.js packages/lib/models/utils/userData.js @@ -1056,13 +1057,15 @@ packages/renderer/MdToHtml/rules/sanitize_html.js packages/renderer/MdToHtml/rules/source_map.js packages/renderer/MdToHtml/setupLinkify.js packages/renderer/MdToHtml/validateLinks.js +packages/renderer/assetsToHeaders.js +packages/renderer/defaultResourceModel.js packages/renderer/headerAnchor.js packages/renderer/highlight.js packages/renderer/htmlUtils.test.js packages/renderer/htmlUtils.js packages/renderer/index.js packages/renderer/noteStyle.js -packages/renderer/pathUtils.js +packages/renderer/types.js packages/renderer/utils.js packages/tools/build-release-stats.test.js packages/tools/build-release-stats.js diff --git a/.gitignore b/.gitignore index eafb8d23cf..bb467bccf4 100644 --- a/.gitignore +++ b/.gitignore @@ -714,6 +714,7 @@ packages/lib/models/utils/itemCanBeEncrypted.js packages/lib/models/utils/paginatedFeed.js packages/lib/models/utils/paginationToSql.js packages/lib/models/utils/readOnly.js +packages/lib/models/utils/resourceUtils.js packages/lib/models/utils/types.js packages/lib/models/utils/userData.test.js packages/lib/models/utils/userData.js @@ -1036,13 +1037,15 @@ packages/renderer/MdToHtml/rules/sanitize_html.js packages/renderer/MdToHtml/rules/source_map.js packages/renderer/MdToHtml/setupLinkify.js packages/renderer/MdToHtml/validateLinks.js +packages/renderer/assetsToHeaders.js +packages/renderer/defaultResourceModel.js packages/renderer/headerAnchor.js packages/renderer/highlight.js packages/renderer/htmlUtils.test.js packages/renderer/htmlUtils.js packages/renderer/index.js packages/renderer/noteStyle.js -packages/renderer/pathUtils.js +packages/renderer/types.js packages/renderer/utils.js packages/tools/build-release-stats.test.js packages/tools/build-release-stats.js diff --git a/packages/app-cli/tests/MarkupToHtml.ts b/packages/app-cli/tests/MarkupToHtml.ts index 273394e3b5..36ad5f582e 100644 --- a/packages/app-cli/tests/MarkupToHtml.ts +++ b/packages/app-cli/tests/MarkupToHtml.ts @@ -1,5 +1,6 @@ -import MarkupToHtml, { MarkupLanguage, RenderResult } from '@joplin/renderer/MarkupToHtml'; +import MarkupToHtml, { MarkupLanguage } from '@joplin/renderer/MarkupToHtml'; +import { RenderResult } from '@joplin/renderer/types'; describe('MarkupToHtml', () => { diff --git a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx index bfb8eb455d..073303595d 100644 --- a/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx +++ b/packages/app-desktop/gui/ConfigScreen/ConfigScreen.tsx @@ -10,7 +10,7 @@ import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigSc import { reg } from '@joplin/lib/registry'; const { connect } = require('react-redux'); const { themeStyle } = require('@joplin/lib/theme'); -const pathUtils = require('@joplin/lib/path-utils'); +import * as pathUtils from '@joplin/lib/path-utils'; import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import * as shared from '@joplin/lib/components/shared/config/config-shared.js'; import ClipperConfigScreen from '../ClipperConfigScreen'; diff --git a/packages/app-desktop/gui/NoteEditor/utils/types.ts b/packages/app-desktop/gui/NoteEditor/utils/types.ts index 28be11ec65..5a4029610a 100644 --- a/packages/app-desktop/gui/NoteEditor/utils/types.ts +++ b/packages/app-desktop/gui/NoteEditor/utils/types.ts @@ -2,7 +2,7 @@ import AsyncActionQueue from '@joplin/lib/AsyncActionQueue'; import { ToolbarButtonInfo } from '@joplin/lib/services/commands/ToolbarButtonUtils'; import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import { MarkupLanguage } from '@joplin/renderer'; -import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/MarkupToHtml'; +import { RenderResult, RenderResultPluginAsset } from '@joplin/renderer/types'; import { MarkupToHtmlOptions } from './useMarkupToHtml'; import { Dispatch } from 'redux'; import { ProcessResultsRow } from '@joplin/lib/services/search/SearchEngine'; diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts index 237b3630fa..4ff8dc9a44 100644 --- a/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useSource.ts @@ -5,7 +5,7 @@ const { themeStyle } = require('../../global-style.js'); import markupLanguageUtils from '@joplin/lib/markupLanguageUtils'; import useEditPopup from './useEditPopup'; import Logger from '@joplin/utils/Logger'; -const { assetsToHeaders } = require('@joplin/renderer'); +import { assetsToHeaders } from '@joplin/renderer'; const logger = Logger.create('NoteBodyViewer/useSource'); diff --git a/packages/lib/models/Resource.ts b/packages/lib/models/Resource.ts index 0baf156183..aaae097a51 100644 --- a/packages/lib/models/Resource.ts +++ b/packages/lib/models/Resource.ts @@ -7,9 +7,9 @@ import markdownUtils from '../markdownUtils'; import { _ } from '../locale'; import { ResourceEntity, ResourceLocalStateEntity, ResourceOcrStatus, SqlQuery } from '../services/database/types'; import ResourceLocalState from './ResourceLocalState'; -const pathUtils = require('../path-utils'); +import * as pathUtils from '../path-utils'; +import { safeFilename } from '../path-utils'; const { mime } = require('../mime-utils.js'); -const { filename, safeFilename } = require('../path-utils'); const { FsDriverDummy } = require('../fs-driver-dummy.js'); import JoplinError from '../JoplinError'; import itemCanBeEncrypted from './utils/itemCanBeEncrypted'; @@ -23,6 +23,7 @@ import { RecognizeResultLine } from '../services/ocr/utils/types'; import eventManager, { EventName } from '../eventManager'; import { unique } from '../array'; import isSqliteSyntaxError from '../services/database/isSqliteSyntaxError'; +import { internalUrl, isResourceUrl, isSupportedImageMimeType, resourceFilename, resourceFullPath, resourcePathToId, resourceRelativePath, resourceUrlToId } from './utils/resourceUtils'; export default class Resource extends BaseItem { @@ -56,8 +57,7 @@ export default class Resource extends BaseItem { } public static isSupportedImageMimeType(type: string) { - const imageMimeTypes = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', 'image/avif']; - return imageMimeTypes.indexOf(type.toLowerCase()) >= 0; + return isSupportedImageMimeType(type); } public static fetchStatuses(resourceIds: string[]): Promise { @@ -121,10 +121,7 @@ export default class Resource extends BaseItem { } public static filename(resource: ResourceEntity, encryptedBlob = false) { - let extension = encryptedBlob ? 'crypted' : resource.file_extension; - if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : ''; - extension = extension ? `.${extension}` : ''; - return resource.id + extension; + return resourceFilename(resource, encryptedBlob); } public static friendlySafeFilename(resource: ResourceEntity) { @@ -137,11 +134,11 @@ export default class Resource extends BaseItem { } public static relativePath(resource: ResourceEntity, encryptedBlob = false) { - return `${Setting.value('resourceDirName')}/${this.filename(resource, encryptedBlob)}`; + return resourceRelativePath(resource, this.baseRelativeDirectoryPath(), encryptedBlob); } public static fullPath(resource: ResourceEntity, encryptedBlob = false) { - return `${Setting.value('resourceDir')}/${this.filename(resource, encryptedBlob)}`; + return resourceFullPath(resource, this.baseDirectoryPath(), encryptedBlob); } public static async isReady(resource: ResourceEntity) { @@ -270,11 +267,11 @@ export default class Resource extends BaseItem { } public static internalUrl(resource: ResourceEntity) { - return `:/${resource.id}`; + return internalUrl(resource); } public static pathToId(path: string) { - return filename(path); + return resourcePathToId(path); } public static async content(resource: ResourceEntity) { @@ -282,12 +279,11 @@ export default class Resource extends BaseItem { } public static isResourceUrl(url: string) { - return url && url.length === 34 && url[0] === ':' && url[1] === '/'; + return isResourceUrl(url); } public static urlToId(url: string) { - if (!this.isResourceUrl(url)) throw new Error(`Not a valid resource URL: ${url}`); - return url.substr(2); + return resourceUrlToId(url); } public static async localState(resourceOrId: any): Promise { diff --git a/packages/lib/models/utils/resourceUtils.ts b/packages/lib/models/utils/resourceUtils.ts new file mode 100644 index 0000000000..aa70173f4a --- /dev/null +++ b/packages/lib/models/utils/resourceUtils.ts @@ -0,0 +1,43 @@ +import type { ResourceEntity } from '../../services/database/types'; +const { mime } = require('../../mime-utils.js'); +import { filename } from '@joplin/utils/path'; + +// This file contains resource-related utilities that do not +// depend on the database, settings, etc. + +export const resourceFilename = (resource: ResourceEntity, encryptedBlob = false) => { + let extension = encryptedBlob ? 'crypted' : resource.file_extension; + if (!extension) extension = resource.mime ? mime.toFileExtension(resource.mime) : ''; + extension = extension ? `.${extension}` : ''; + return resource.id + extension; +}; + +export const resourceRelativePath = (resource: ResourceEntity, relativeResourceDirPath: string, encryptedBlob = false) => { + return `${relativeResourceDirPath}/${resourceFilename(resource, encryptedBlob)}`; +}; + +export const resourceFullPath = (resource: ResourceEntity, resourceDirPath: string, encryptedBlob = false) => { + return `${resourceDirPath}/${resourceFilename(resource, encryptedBlob)}`; +}; + +export const internalUrl = (resource: ResourceEntity) => { + return `:/${resource.id}`; +}; + +export const resourcePathToId = (path: string) => { + return filename(path); +}; + +export const isResourceUrl = (url: string) => { + return url && url.length === 34 && url[0] === ':' && url[1] === '/'; +}; + +export const resourceUrlToId = (url: string) => { + if (!isResourceUrl(url)) throw new Error(`Not a valid resource URL: ${url}`); + return url.substring(2); +}; + +export const isSupportedImageMimeType = (type: string) => { + const imageMimeTypes = ['image/jpg', 'image/jpeg', 'image/png', 'image/gif', 'image/svg+xml', 'image/webp', 'image/avif']; + return imageMimeTypes.indexOf(type.toLowerCase()) >= 0; +}; diff --git a/packages/lib/path-utils.ts b/packages/lib/path-utils.ts index f37ec6162e..287312b62e 100644 --- a/packages/lib/path-utils.ts +++ b/packages/lib/path-utils.ts @@ -1,65 +1,8 @@ /* eslint no-useless-escape: 0*/ -const { _ } = require('./locale'); - -export function dirname(path: string) { - if (!path) throw new Error('Path is empty'); - const s = path.split(/\/|\\/); - s.pop(); - return s.join('/'); -} - -export function basename(path: string) { - if (!path) throw new Error('Path is empty'); - const s = path.split(/\/|\\/); - return s[s.length - 1]; -} - -export function filename(path: string, includeDir = false) { - if (!path) throw new Error('Path is empty'); - const output = includeDir ? path : basename(path); - if (output.indexOf('.') < 0) return output; - - const splitted = output.split('.'); - splitted.pop(); - return splitted.join('.'); -} - -export function fileExtension(path: string) { - if (!path) throw new Error('Path is empty'); - - const output = path.split('.'); - if (output.length <= 1) return ''; - return output[output.length - 1]; -} - -export function isHidden(path: string) { - const b = basename(path); - if (!b.length) throw new Error(`Path empty or not a valid path: ${path}`); - return b[0] === '.'; -} - -// Note that this function only sanitizes a file extension - it does NOT extract -// the file extension from a filename. So the way you'd normally call this is -// `safeFileExtension(fileExtension(filename))` -export function safeFileExtension(e: string, maxLength: number = null) { - // In theory the file extension can have any length but in practice Joplin - // expects a fixed length, so we limit it to 20 which should cover most cases. - // Note that it means that a file extension longer than 20 will break - // external editing (since the extension would be truncated). - // https://discourse.joplinapp.org/t/troubles-with-webarchive-files-on-ios/10447 - if (maxLength === null) maxLength = 20; - if (!e || !e.replace) return ''; - return e.replace(/[^a-zA-Z0-9]/g, '').substr(0, maxLength); -} - -export function safeFilename(e: string, maxLength: number = null, allowSpaces = false) { - if (maxLength === null) maxLength = 32; - if (!e || !e.replace) return ''; - const regex = allowSpaces ? /[^a-zA-Z0-9\-_\(\)\. ]/g : /[^a-zA-Z0-9\-_\(\)\.]/g; - const output = e.replace(regex, '_'); - return output.substr(0, maxLength); -} +import { _ } from './locale'; +import { fileExtension, filename, safeFileExtension } from '@joplin/utils/path'; +export * from '@joplin/utils/path'; let friendlySafeFilename_blackListChars = '/\n\r<>:\'"\\|?*#'; for (let i = 0; i < 32; i++) { @@ -129,77 +72,3 @@ export function friendlySafeFilename(e: string, maxLength: number = null, preser return output.substr(0, maxLength) + fileExt; } - -export function toFileProtocolPath(filePathEncode: string, os: string = null) { - if (os === null) os = process.platform; - - if (os === 'win32') { - filePathEncode = filePathEncode.replace(/\\/g, '/'); // replace backslash in windows pathname with slash e.g. c:\temp to c:/temp - filePathEncode = `/${filePathEncode}`; // put slash in front of path to comply with windows fileURL syntax - } - - filePathEncode = encodeURI(filePathEncode); - filePathEncode = filePathEncode.replace(/\+/g, '%2B'); // escape '+' with unicode - return `file://${filePathEncode.replace(/\'/g, '%27')}`; // escape '(single quote) with unicode, to prevent crashing the html view -} - -export function toSystemSlashes(path: string, os: string = null) { - if (os === null) os = process.platform; - if (os === 'win32') return path.replace(/\//g, '\\'); - return path.replace(/\\/g, '/'); -} - -export function toForwardSlashes(path: string) { - return toSystemSlashes(path, 'linux'); -} - -export function rtrimSlashes(path: string) { - return path.replace(/[\/\\]+$/, ''); -} - -export function ltrimSlashes(path: string) { - return path.replace(/^\/+/, ''); -} - -export function trimSlashes(path: string): string { - return ltrimSlashes(rtrimSlashes(path)); -} - -export function quotePath(path: string) { - if (!path) return ''; - if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path; - path = path.replace(/"/, '\\"'); - return `"${path}"`; -} - -export function unquotePath(path: string) { - if (!path.length) return ''; - if (path.length && path[0] === '"') { - path = path.substr(1, path.length - 2); - } - path = path.replace(/\\"/, '"'); - return path; -} - -export function extractExecutablePath(cmd: string) { - if (!cmd.length) return ''; - - const quoteType = ['"', '\''].indexOf(cmd[0]) >= 0 ? cmd[0] : ''; - - let output = ''; - for (let i = 0; i < cmd.length; i++) { - const c = cmd[i]; - if (quoteType) { - if (i > 0 && c === quoteType) { - output += c; - break; - } - } else { - if (c === ' ') break; - } - - output += c; - } - - return output; -} diff --git a/packages/lib/pathUtils.test.js b/packages/lib/pathUtils.test.js index f93fe68869..1a3da17a37 100644 --- a/packages/lib/pathUtils.test.js +++ b/packages/lib/pathUtils.test.js @@ -1,9 +1,7 @@ -const { extractExecutablePath, quotePath, unquotePath, friendlySafeFilename, toFileProtocolPath } = require('./path-utils'); +const { friendlySafeFilename } = require('./path-utils'); describe('pathUtils', () => { - - it('should create friendly safe filename', (async () => { const testCases = [ ['生活', '生活'], @@ -32,59 +30,4 @@ describe('pathUtils', () => { expect(friendlySafeFilename('thatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylongthatsreallylong.md', null, true)).toBe('thatsreallylongthatsreallylongthatsreallylongthats.md'); })); - it('should quote and unquote paths', (async () => { - const testCases = [ - ['', ''], - ['/my/path', '/my/path'], - ['/my/path with spaces', '"/my/path with spaces"'], - ['/my/weird"path', '"/my/weird\\"path"'], - ['c:\\Windows\\test.dll', 'c:\\Windows\\test.dll'], - ['c:\\Windows\\test test.dll', '"c:\\Windows\\test test.dll"'], - ]; - - for (let i = 0; i < testCases.length; i++) { - const t = testCases[i]; - expect(quotePath(t[0])).toBe(t[1]); - expect(unquotePath(quotePath(t[0]))).toBe(t[0]); - } - })); - - it('should extract executable path from command', (async () => { - const testCases = [ - ['', ''], - ['/my/cmd -some -args', '/my/cmd'], - ['"/my/cmd" -some -args', '"/my/cmd"'], - ['"/my/cmd"', '"/my/cmd"'], - ['"/my/cmd and space" -some -flags', '"/my/cmd and space"'], - ['"" -some -flags', '""'], - ]; - - for (let i = 0; i < testCases.length; i++) { - const t = testCases[i]; - expect(extractExecutablePath(t[0])).toBe(t[1]); - } - })); - - it('should create correct fileURL syntax', (async () => { - const testCases_win32 = [ - ['C:\\handle\\space test', 'file:///C:/handle/space%20test'], - ['C:\\escapeplus\\+', 'file:///C:/escapeplus/%2B'], - ['C:\\handle\\single quote\'', 'file:///C:/handle/single%20quote%27'], - ]; - const testCases_unixlike = [ - ['/handle/space test', 'file:///handle/space%20test'], - ['/escapeplus/+', 'file:///escapeplus/%2B'], - ['/handle/single quote\'', 'file:///handle/single%20quote%27'], - ]; - - for (let i = 0; i < testCases_win32.length; i++) { - const t = testCases_win32[i]; - expect(toFileProtocolPath(t[0], 'win32')).toBe(t[1]); - } - for (let i = 0; i < testCases_unixlike.length; i++) { - const t = testCases_unixlike[i]; - expect(toFileProtocolPath(t[0], 'linux')).toBe(t[1]); - } - })); - }); diff --git a/packages/lib/services/interop/InteropService_Exporter_Html.ts b/packages/lib/services/interop/InteropService_Exporter_Html.ts index 45a96694c8..b782e64f20 100644 --- a/packages/lib/services/interop/InteropService_Exporter_Html.ts +++ b/packages/lib/services/interop/InteropService_Exporter_Html.ts @@ -12,7 +12,7 @@ import { basename, friendlySafeFilename, rtrimSlashes, dirname } from '../../pat import htmlpack from '@joplin/htmlpack'; const { themeStyle } = require('../../theme'); const { escapeHtml } = require('../../string-utils.js'); -const { assetsToHeaders } = require('@joplin/renderer'); +import { assetsToHeaders } from '@joplin/renderer'; export default class InteropService_Exporter_Html extends InteropService_Exporter_Base { diff --git a/packages/lib/services/plugins/utils/loadContentScripts.ts b/packages/lib/services/plugins/utils/loadContentScripts.ts index cbdf5d9c8e..836be9f63d 100644 --- a/packages/lib/services/plugins/utils/loadContentScripts.ts +++ b/packages/lib/services/plugins/utils/loadContentScripts.ts @@ -1,6 +1,6 @@ import { PluginStates } from '../reducer'; import { ContentScriptType, ContentScriptContext, PostMessageHandler, ContentScriptModule } from '../api/types'; -import { dirname } from '@joplin/renderer/pathUtils'; +import { dirname } from '@joplin/utils/path'; import shim from '../../../shim'; import Logger from '@joplin/utils/Logger'; import PluginService from '../PluginService'; diff --git a/packages/renderer/HtmlToHtml.ts b/packages/renderer/HtmlToHtml.ts index 552d12a4c5..3736c61bcb 100644 --- a/packages/renderer/HtmlToHtml.ts +++ b/packages/renderer/HtmlToHtml.ts @@ -1,10 +1,10 @@ import htmlUtils from './htmlUtils'; import linkReplacement from './MdToHtml/linkReplacement'; -import utils, { ItemIdToUrlHandler } from './utils'; +import * as utils from './utils'; import InMemoryCache from './InMemoryCache'; -import { RenderResult } from './MarkupToHtml'; import noteStyle, { whiteBackgroundNoteStyle } from './noteStyle'; import { Options as NoteStyleOptions } from './noteStyle'; +import { FsDriver, MarkupRenderer, OptionsResourceModel, RenderOptions, RenderResult } from './types'; const md5 = require('md5'); // Renderered notes can potentially be quite large (for example @@ -17,38 +17,12 @@ export interface SplittedHtml { css: string; } -interface FsDriver { - // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied - writeFile: Function; - // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied - exists: Function; - // eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied - cacheCssToFile: Function; -} - interface Options { - ResourceModel: any; + ResourceModel: OptionsResourceModel; resourceBaseUrl?: string; fsDriver?: FsDriver; } -interface RenderOptions { - splitted: boolean; - bodyOnly: boolean; - externalAssetsOnly: boolean; - resources: any; - postMessageSyntax: string; - enableLongPress: boolean; - itemIdToUrl?: ItemIdToUrlHandler; - allowedFilePrefixes?: string[]; - whiteBackgroundNoteRendering?: boolean; - - // For compatibility with MdToHtml options: - plugins?: { - link_open?: { linkRenderingType?: number }; - }; -} - // https://github.com/es-shims/String.prototype.trimStart/blob/main/implementation.js function trimStart(s: string): string { // eslint-disable-next-line no-control-regex @@ -56,12 +30,12 @@ function trimStart(s: string): string { return s.replace(startWhitespace, ''); } -export default class HtmlToHtml { +export default class HtmlToHtml implements MarkupRenderer { private resourceBaseUrl_; private ResourceModel_; private cache_; - private fsDriver_: any; + private fsDriver_: FsDriver; public constructor(options: Options = null) { options = { @@ -101,6 +75,10 @@ export default class HtmlToHtml { return [await this.fsDriver().cacheCssToFile(cssStrings)]; } + public clearCache(): void { + // TODO: Clear the in-memory cache + } + // Note: the "theme" variable is ignored and instead the light theme is // always used for HTML notes. // See: https://github.com/laurent22/joplin/issues/3698 diff --git a/packages/renderer/MarkupToHtml.ts b/packages/renderer/MarkupToHtml.ts index bd03bb4b89..6d4b3d5ee2 100644 --- a/packages/renderer/MarkupToHtml.ts +++ b/packages/renderer/MarkupToHtml.ts @@ -3,6 +3,8 @@ import HtmlToHtml from './HtmlToHtml'; import htmlUtils from './htmlUtils'; import { Options as NoteStyleOptions } from './noteStyle'; import { AllHtmlEntities } from 'html-entities'; +import { FsDriver, MarkupRenderer, MarkupToHtmlConverter, OptionsResourceModel, RenderResult } from './types'; +import defaultResourceModel from './defaultResourceModel'; const MarkdownIt = require('markdown-it'); export enum MarkupLanguage { @@ -11,29 +13,6 @@ export enum MarkupLanguage { Any = 3, } -export interface RenderResultPluginAsset { - name: string; - mime: string; - path: string; - - // For built-in Mardown-it plugins, the asset path is relative (and can be - // found inside the @joplin/renderer package), while for external plugins - // (content scripts), the path is absolute. We use this property to tell if - // it's relative or absolute, as that will inform how it's loaded in various - // places. - pathIsAbsolute: boolean; -} - -export interface RenderResult { - html: string; - pluginAssets: RenderResultPluginAsset[]; - cssStrings: string[]; -} - -export interface OptionsResourceModel { - isResourceUrl: (url: string)=> boolean; -} - export interface Options { isSafeMode?: boolean; ResourceModel?: OptionsResourceModel; @@ -42,23 +21,21 @@ export interface Options { resourceBaseUrl?: string; pluginOptions?: any; // Not sure if needed tempDir?: string; // Not sure if needed - fsDriver?: any; // Not sure if needed + fsDriver?: FsDriver; // Not sure if needed } -export default class MarkupToHtml { +export default class MarkupToHtml implements MarkupToHtmlConverter { public static MARKUP_LANGUAGE_MARKDOWN: number = MarkupLanguage.Markdown; public static MARKUP_LANGUAGE_HTML: number = MarkupLanguage.Html; - private renderers_: any = {}; + private renderers_: Record = {}; private options_: Options; private rawMarkdownIt_: any; public constructor(options: Options = null) { this.options_ = { - ResourceModel: { - isResourceUrl: () => false, - }, + ResourceModel: defaultResourceModel, isSafeMode: false, ...options, }; diff --git a/packages/renderer/MdToHtml.ts b/packages/renderer/MdToHtml.ts index 978a846676..bf5f733dfc 100644 --- a/packages/renderer/MdToHtml.ts +++ b/packages/renderer/MdToHtml.ts @@ -1,11 +1,10 @@ import InMemoryCache from './InMemoryCache'; import noteStyle from './noteStyle'; -import { fileExtension } from './pathUtils'; +import { fileExtension } from '@joplin/utils/path'; import setupLinkify from './MdToHtml/setupLinkify'; import validateLinks from './MdToHtml/validateLinks'; -import { ItemIdToUrlHandler } from './utils'; -import { RenderResult, RenderResultPluginAsset } from './MarkupToHtml'; import { Options as NoteStyleOptions } from './noteStyle'; +import { FsDriver, ItemIdToUrlHandler, MarkupRenderer, OptionsResourceModel, RenderOptions, RenderResult, RenderResultPluginAsset } from './types'; import hljs from './highlight'; import * as MarkdownIt from 'markdown-it'; @@ -13,28 +12,6 @@ const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; const md5 = require('md5'); -export interface RenderOptions { - contentMaxWidth?: number; - bodyOnly?: boolean; - splitted?: boolean; - externalAssetsOnly?: boolean; - postMessageSyntax?: string; - highlightedKeywords?: string[]; - codeTheme?: string; - theme?: any; - plugins?: Record; - audioPlayerEnabled?: boolean; - videoPlayerEnabled?: boolean; - pdfViewerEnabled?: boolean; - codeHighlightCacheKey?: string; - plainResourceRendering?: boolean; - mapsToLine?: boolean; - useCustomPdfViewer?: boolean; - noteId?: string; - vendorDir?: string; - settingValue?: (pluginId: string, key: string)=> any; -} - interface RendererRule { install(context: any, ruleOptions: any): any; assets?(theme: any): any; @@ -109,10 +86,10 @@ export interface ExtraRendererRule { export interface Options { resourceBaseUrl?: string; - ResourceModel?: any; + ResourceModel?: OptionsResourceModel; pluginOptions?: any; tempDir?: string; - fsDriver?: any; + fsDriver?: FsDriver; extraRendererRules?: ExtraRendererRule[]; customCss?: string; } @@ -150,7 +127,7 @@ export interface RuleOptions { context: PluginContext; theme: any; postMessageSyntax: string; - ResourceModel: any; + ResourceModel: OptionsResourceModel; resourceBaseUrl: string; resources: any; // resourceId: Resource @@ -199,12 +176,12 @@ export interface RuleOptions { platformName?: string; } -export default class MdToHtml { +export default class MdToHtml implements MarkupRenderer { private resourceBaseUrl_: string; - private ResourceModel_: any; + private ResourceModel_: OptionsResourceModel; private contextCache_: any; - private fsDriver_: any; + private fsDriver_: FsDriver; private cachedOutputs_: any = {}; private lastCodeHighlightCacheKey_: string = null; diff --git a/packages/renderer/MdToHtml/createEventHandlingAttrs.ts b/packages/renderer/MdToHtml/createEventHandlingAttrs.ts index aaa060c866..9fe816e026 100644 --- a/packages/renderer/MdToHtml/createEventHandlingAttrs.ts +++ b/packages/renderer/MdToHtml/createEventHandlingAttrs.ts @@ -1,6 +1,6 @@ -import utils from '../utils'; +import * as utils from '../utils'; export interface Options { diff --git a/packages/renderer/MdToHtml/linkReplacement.test.ts b/packages/renderer/MdToHtml/linkReplacement.test.ts index c9898e90ca..feb6d39416 100644 --- a/packages/renderer/MdToHtml/linkReplacement.test.ts +++ b/packages/renderer/MdToHtml/linkReplacement.test.ts @@ -1,3 +1,4 @@ +import defaultResourceModel from '../defaultResourceModel'; import linkReplacement from './linkReplacement'; import { describe, test, expect } from '@jest/globals'; @@ -24,7 +25,7 @@ describe('linkReplacement', () => { const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c'; const r = linkReplacement(`:/${resourceId}`, { - ResourceModel: {}, + ResourceModel: defaultResourceModel, resources: { [resourceId]: { item: {}, @@ -42,7 +43,7 @@ describe('linkReplacement', () => { const resourceId = 'f6afba55bdf74568ac94f8d1e3578d2c'; const r = linkReplacement(`:/${resourceId}`, { - ResourceModel: {}, + ResourceModel: defaultResourceModel, resources: { [resourceId]: { item: {}, @@ -62,7 +63,7 @@ describe('linkReplacement', () => { const resourceId = 'e6afba55bdf74568ac94f8d1e3578d2c'; const linkHtml = linkReplacement(`:/${resourceId}`, { - ResourceModel: {}, + ResourceModel: defaultResourceModel, resources: { [resourceId]: { item: {}, diff --git a/packages/renderer/MdToHtml/linkReplacement.ts b/packages/renderer/MdToHtml/linkReplacement.ts index 80662e0521..a8623a2f07 100644 --- a/packages/renderer/MdToHtml/linkReplacement.ts +++ b/packages/renderer/MdToHtml/linkReplacement.ts @@ -1,4 +1,5 @@ -import utils, { ItemIdToUrlHandler } from '../utils'; +import { ItemIdToUrlHandler, OptionsResourceModel } from '../types'; +import * as utils from '../utils'; import createEventHandlingAttrs from './createEventHandlingAttrs'; const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; @@ -8,7 +9,7 @@ const { getClassNameForMimeType } = require('font-awesome-filetypes'); export interface Options { title?: string; resources?: any; - ResourceModel?: any; + ResourceModel?: OptionsResourceModel; linkRenderingType?: number; plainResourceRendering?: boolean; postMessageSyntax?: string; diff --git a/packages/renderer/MdToHtml/renderMedia.ts b/packages/renderer/MdToHtml/renderMedia.ts index 0ca02fad87..a15caf6d16 100644 --- a/packages/renderer/MdToHtml/renderMedia.ts +++ b/packages/renderer/MdToHtml/renderMedia.ts @@ -1,5 +1,5 @@ import { Link } from '../MdToHtml'; -import { toForwardSlashes } from '../pathUtils'; +import { toForwardSlashes } from '@joplin/utils/path'; import { LinkIndexes } from './rules/link_close'; const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; diff --git a/packages/renderer/MdToHtml/rules/html_image.ts b/packages/renderer/MdToHtml/rules/html_image.ts index 660037d19b..210bd8d959 100644 --- a/packages/renderer/MdToHtml/rules/html_image.ts +++ b/packages/renderer/MdToHtml/rules/html_image.ts @@ -1,6 +1,6 @@ import { RuleOptions } from '../../MdToHtml'; import { attributesHtml } from '../../htmlUtils'; -import utils from '../../utils'; +import * as utils from '../../utils'; function renderImageHtml(before: string, src: string, after: string, ruleOptions: RuleOptions) { const r = utils.imageReplacement(ruleOptions.ResourceModel, src, ruleOptions.resources, ruleOptions.resourceBaseUrl, ruleOptions.itemIdToUrl); diff --git a/packages/renderer/MdToHtml/rules/image.ts b/packages/renderer/MdToHtml/rules/image.ts index 4f170719fa..27c82669d7 100644 --- a/packages/renderer/MdToHtml/rules/image.ts +++ b/packages/renderer/MdToHtml/rules/image.ts @@ -1,6 +1,6 @@ import { RuleOptions } from '../../MdToHtml'; import { attributesHtml } from '../../htmlUtils'; -import utils from '../../utils'; +import * as utils from '../../utils'; import createEventHandlingAttrs from '../createEventHandlingAttrs'; function plugin(markdownIt: any, ruleOptions: RuleOptions) { diff --git a/packages/renderer/MdToHtml/rules/link_open.ts b/packages/renderer/MdToHtml/rules/link_open.ts index ce6b39553b..b13c243280 100644 --- a/packages/renderer/MdToHtml/rules/link_open.ts +++ b/packages/renderer/MdToHtml/rules/link_open.ts @@ -1,6 +1,6 @@ import { RuleOptions } from '../../MdToHtml'; import linkReplacement from '../linkReplacement'; -import utils from '../../utils'; +import * as utils from '../../utils'; const urlUtils = require('../../urlUtils.js'); diff --git a/packages/renderer/assetsToHeaders.js b/packages/renderer/assetsToHeaders.ts similarity index 72% rename from packages/renderer/assetsToHeaders.js rename to packages/renderer/assetsToHeaders.ts index 46cab33e9b..fd42b7f0aa 100644 --- a/packages/renderer/assetsToHeaders.js +++ b/packages/renderer/assetsToHeaders.ts @@ -1,9 +1,15 @@ +import { RenderResultPluginAsset } from './types'; + +interface Options { + asHtml: boolean; +} + // Utility function to convert the plugin assets to a list of LINK or SCRIPT tags // that can be included in the HEAD tag. -function assetsToHeaders(pluginAssets, options = null) { +const assetsToHeaders = (pluginAssets: RenderResultPluginAsset[], options: Options|null = null) => { options = { asHtml: false, ...options }; - const headers = {}; + const headers: Record = {}; for (let i = 0; i < pluginAssets.length; i++) { const asset = pluginAssets[i]; if (asset.mime === 'text/css') { @@ -23,6 +29,6 @@ function assetsToHeaders(pluginAssets, options = null) { } return headers; -} +}; -module.exports = assetsToHeaders; +export default assetsToHeaders; diff --git a/packages/renderer/defaultResourceModel.ts b/packages/renderer/defaultResourceModel.ts new file mode 100644 index 0000000000..86787c2d3f --- /dev/null +++ b/packages/renderer/defaultResourceModel.ts @@ -0,0 +1,16 @@ +import { OptionsResourceModel } from './types'; + +// Used for tests and when no ResourceModel is provided. + +const defaultResourceModel: OptionsResourceModel = { + isResourceUrl: (_url: string) => false, + urlToId: _url => { + throw new Error('Not implemented: urlToId'); + }, + filename: _url => { + throw new Error('Not implemented: filename'); + }, + isSupportedImageMimeType: _type => false, +}; + +export default defaultResourceModel; diff --git a/packages/renderer/index.ts b/packages/renderer/index.ts index f311b67422..966266ac4d 100644 --- a/packages/renderer/index.ts +++ b/packages/renderer/index.ts @@ -1,11 +1,11 @@ import MarkupToHtml, { MarkupLanguage } from './MarkupToHtml'; import MdToHtml from './MdToHtml'; import HtmlToHtml from './HtmlToHtml'; -import utils from './utils'; +import * as utils from './utils'; import setupLinkify from './MdToHtml/setupLinkify'; import validateLinks from './MdToHtml/validateLinks'; import headerAnchor from './headerAnchor'; -const assetsToHeaders = require('./assetsToHeaders'); +import assetsToHeaders from './assetsToHeaders'; export { MarkupToHtml, diff --git a/packages/renderer/pathUtils.ts b/packages/renderer/pathUtils.ts deleted file mode 100644 index 58a3fe944a..0000000000 --- a/packages/renderer/pathUtils.ts +++ /dev/null @@ -1,34 +0,0 @@ -export function dirname(path: string) { - if (!path) throw new Error('Path is empty'); - const s = path.split(/\/|\\/); - s.pop(); - return s.join('/'); -} - -export function basename(path: string) { - if (!path) throw new Error('Path is empty'); - const s = path.split(/\/|\\/); - return s[s.length - 1]; -} - -export function filename(path: string, includeDir = false): string { - if (!path) throw new Error('Path is empty'); - const output = includeDir ? path : basename(path); - if (output.indexOf('.') < 0) return output; - - const splitted = output.split('.'); - splitted.pop(); - return splitted.join('.'); -} - -export function fileExtension(path: string) { - if (!path) throw new Error('Path is empty'); - - const output = path.split('.'); - if (output.length <= 1) return ''; - return output[output.length - 1]; -} - -export function toForwardSlashes(path: string) { - return path.replace(/\\/g, '/'); -} diff --git a/packages/renderer/types.ts b/packages/renderer/types.ts new file mode 100644 index 0000000000..34b9f4b05d --- /dev/null +++ b/packages/renderer/types.ts @@ -0,0 +1,95 @@ +import { MarkupLanguage } from './MarkupToHtml'; +import { Options as NoteStyleOptions } from './noteStyle'; + +export type ItemIdToUrlHandler = (resource: any)=> string; + +interface ResourceEntity { + id: string; + title?: string; + mime?: string; + file_extension?: string; +} + +export interface FsDriver { + writeFile: (path: string, content: string, encoding: string)=> Promise; + exists: (path: string)=> Promise; + cacheCssToFile: (cssStrings: string[])=> Promise; +} + +export interface RenderOptions { + contentMaxWidth?: number; + bodyOnly?: boolean; + splitted?: boolean; + enableLongPress?: boolean; + postMessageSyntax?: string; + + externalAssetsOnly?: boolean; + highlightedKeywords?: string[]; + codeTheme?: string; + theme?: any; + + plugins?: Record; + audioPlayerEnabled?: boolean; + videoPlayerEnabled?: boolean; + pdfViewerEnabled?: boolean; + + codeHighlightCacheKey?: string; + plainResourceRendering?: boolean; + + mapsToLine?: boolean; + useCustomPdfViewer?: boolean; + noteId?: string; + vendorDir?: string; + itemIdToUrl?: ItemIdToUrlHandler; + allowedFilePrefixes?: string[]; + settingValue?: (pluginId: string, key: string)=> any; + + resources?: Record; + + // HtmlToHtml only + whiteBackgroundNoteRendering?: boolean; +} + +export interface RenderResultPluginAsset { + name: string; + mime: string; + path: string; + + // For built-in Mardown-it plugins, the asset path is relative (and can be + // found inside the @joplin/renderer package), while for external plugins + // (content scripts), the path is absolute. We use this property to tell if + // it's relative or absolute, as that will inform how it's loaded in various + // places. + pathIsAbsolute: boolean; +} + +export interface RenderResult { + html: string; + pluginAssets: RenderResultPluginAsset[]; + cssStrings: string[]; +} + +export interface MarkupRenderer { + render(markup: string, theme: any, options: RenderOptions): Promise; + clearCache(): void; + allAssets(theme: any, noteStyleOptions: NoteStyleOptions|null): Promise; +} + +interface StripMarkupOptions { + collapseWhiteSpaces: boolean; +} + +export interface MarkupToHtmlConverter { + render(markupLanguage: MarkupLanguage, markup: string, theme: any, options: any): Promise; + clearCache(markupLanguage: MarkupLanguage): void; + stripMarkup(markupLanguage: MarkupLanguage, markup: string, options: StripMarkupOptions): string; + allAssets(markupLanguage: MarkupLanguage, theme: any, noteStyleOptions: NoteStyleOptions|null): Promise; +} + +export interface OptionsResourceModel { + isResourceUrl: (url: string)=> boolean; + urlToId: (url: string)=> string; + filename: (resource: ResourceEntity, encryptedBlob?: boolean)=> string; + isSupportedImageMimeType: (type: string)=> boolean; + fullPath?: (resource: ResourceEntity, encryptedBlob?: boolean)=> string; +} diff --git a/packages/renderer/utils.ts b/packages/renderer/utils.ts index f4c700480a..378dc8bc03 100644 --- a/packages/renderer/utils.ts +++ b/packages/renderer/utils.ts @@ -1,3 +1,5 @@ +import { ItemIdToUrlHandler, OptionsResourceModel } from './types'; + const Entities = require('html-entities').AllHtmlEntities; const htmlentities = new Entities().encode; @@ -9,16 +11,14 @@ const FetchStatuses = { FETCH_STATUS_ERROR: 3, }; -const utils: any = {}; - -utils.getAttr = function(attrs: string[], name: string, defaultValue: string = null) { +export const getAttr = function(attrs: string[], name: string, defaultValue: string = null) { for (let i = 0; i < attrs.length; i++) { if (attrs[i][0] === name) return attrs[i].length > 1 ? attrs[i][1] : null; } return defaultValue; }; -utils.notDownloadedResource = function() { +export const notDownloadedResource = function() { return ` @@ -26,7 +26,7 @@ utils.notDownloadedResource = function() { `; }; -utils.notDownloadedImage = function() { +export const notDownloadedImage = function() { // https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-image-o.svg // Height changed to 1795 return ` @@ -36,7 +36,7 @@ utils.notDownloadedImage = function() { `; }; -utils.notDownloadedFile = function() { +export const notDownloadedFile = function() { // https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/file-o.svg return ` @@ -45,7 +45,7 @@ utils.notDownloadedFile = function() { `; }; -utils.errorImage = function() { +export const errorImage = function() { // https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/times-circle.svg return ` @@ -54,7 +54,7 @@ utils.errorImage = function() { `; }; -utils.loaderImage = function() { +export const loaderImage = function() { // https://github.com/ForkAwesome/Fork-Awesome/blob/master/src/icons/svg/hourglass-half.svg return ` @@ -63,21 +63,21 @@ utils.loaderImage = function() { `; }; -utils.resourceStatusImage = function(status: string) { - if (status === 'notDownloaded') return utils.notDownloadedResource(); - return utils.resourceStatusFile(status); +export const resourceStatusImage = function(status: string) { + if (status === 'notDownloaded') return notDownloadedResource(); + return resourceStatusFile(status); }; -utils.resourceStatusFile = function(status: string) { - if (status === 'notDownloaded') return utils.notDownloadedResource(); - if (status === 'downloading') return utils.loaderImage(); - if (status === 'encrypted') return utils.loaderImage(); - if (status === 'error') return utils.errorImage(); +export const resourceStatusFile = function(status: string) { + if (status === 'notDownloaded') return notDownloadedResource(); + if (status === 'downloading') return loaderImage(); + if (status === 'encrypted') return loaderImage(); + if (status === 'error') return errorImage(); throw new Error(`Unknown status: ${status}`); }; -utils.resourceStatusIndex = function(status: string) { +export const resourceStatusIndex = function(status: string) { if (status === 'error') return -1; if (status === 'notDownloaded') return 0; if (status === 'downloading') return 1; @@ -87,7 +87,7 @@ utils.resourceStatusIndex = function(status: string) { throw new Error(`Unknown status: ${status}`); }; -utils.resourceStatusName = function(index: number) { +export const resourceStatusName = function(index: number) { if (index === -1) return 'error'; if (index === 0) return 'notDownloaded'; if (index === 1) return 'downloading'; @@ -97,34 +97,32 @@ utils.resourceStatusName = function(index: number) { throw new Error(`Unknown index: ${index}`); }; -utils.resourceStatus = function(ResourceModel: any, resourceInfo: any) { +export const resourceStatus = function(ResourceModel: OptionsResourceModel, resourceInfo: any) { if (!ResourceModel) return 'ready'; - let resourceStatus = 'ready'; + let status = 'ready'; if (resourceInfo) { const resource = resourceInfo.item; const localState = resourceInfo.localState; if (localState.fetch_status === FetchStatuses.FETCH_STATUS_IDLE) { - resourceStatus = 'notDownloaded'; + status = 'notDownloaded'; } else if (localState.fetch_status === FetchStatuses.FETCH_STATUS_STARTED) { - resourceStatus = 'downloading'; + status = 'downloading'; } else if (localState.fetch_status === FetchStatuses.FETCH_STATUS_DONE) { if (resource.encryption_blob_encrypted || resource.encryption_applied) { - resourceStatus = 'encrypted'; + status = 'encrypted'; } } } else { - resourceStatus = 'notDownloaded'; + status = 'notDownloaded'; } - return resourceStatus; + return status; }; -export type ItemIdToUrlHandler = (resource: any)=> string; - -utils.imageReplacement = function(ResourceModel: any, src: string, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) { +export const imageReplacement = function(ResourceModel: OptionsResourceModel, src: string, resources: any, resourceBaseUrl: string, itemIdToUrl: ItemIdToUrlHandler = null) { if (!ResourceModel || !resources) return null; if (!ResourceModel.isResourceUrl(src)) return null; @@ -132,11 +130,11 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an const resourceId = ResourceModel.urlToId(src); const result = resources[resourceId]; const resource = result ? result.item : null; - const resourceStatus = utils.resourceStatus(ResourceModel, result); + const status = resourceStatus(ResourceModel, result); - if (resourceStatus !== 'ready') { - const icon = utils.resourceStatusImage(resourceStatus); - return `
` + `` + '
'; + if (status !== 'ready') { + const icon = resourceStatusImage(status); + return `
` + `` + '
'; } const mime = resource.mime ? resource.mime.toLowerCase() : ''; if (ResourceModel.isSupportedImageMimeType(mime)) { @@ -172,6 +170,4 @@ utils.imageReplacement = function(ResourceModel: any, src: string, resources: an // Used in mobile app when enableLongPress = true. Tells for how long // the resource should be pressed before the menu is shown. -utils.longPressDelay = 500; - -export default utils; +export const longPressDelay = 500; diff --git a/packages/server/src/utils/joplinUtils.ts b/packages/server/src/utils/joplinUtils.ts index 2d7c5f8797..8466250094 100644 --- a/packages/server/src/utils/joplinUtils.ts +++ b/packages/server/src/utils/joplinUtils.ts @@ -16,7 +16,7 @@ import { NoteEntity } from '@joplin/lib/services/database/types'; import { formatDateTime } from './time'; import { ErrorBadRequest, ErrorForbidden, ErrorNotFound } from './errors'; import { MarkupToHtml } from '@joplin/renderer'; -import { OptionsResourceModel } from '@joplin/renderer/MarkupToHtml'; +import { OptionsResourceModel } from '@joplin/renderer/types'; import { isValidHeaderIdentifier } from '@joplin/lib/services/e2ee/EncryptionService'; const { DatabaseDriverNode } = require('@joplin/lib/database-driver-node.js'); import { themeStyle } from '@joplin/lib/theme'; diff --git a/packages/utils/package.json b/packages/utils/package.json index e71ba35606..90120ffaf0 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -13,7 +13,8 @@ "./net": "./dist/net.js", "./time": "./dist/time.js", "./types": "./dist/types.js", - "./url": "./dist/url.js" + "./url": "./dist/url.js", + "./path": "./dist/path.js" }, "publishConfig": { "access": "public" diff --git a/packages/utils/path.test.ts b/packages/utils/path.test.ts new file mode 100644 index 0000000000..b58a3bb481 --- /dev/null +++ b/packages/utils/path.test.ts @@ -0,0 +1,58 @@ +import { extractExecutablePath, quotePath, toFileProtocolPath, unquotePath } from './path'; + +describe('path', () => { + it('should quote and unquote paths', (async () => { + const testCases = [ + ['', ''], + ['/my/path', '/my/path'], + ['/my/path with spaces', '"/my/path with spaces"'], + ['/my/weird"path', '"/my/weird\\"path"'], + ['c:\\Windows\\test.dll', 'c:\\Windows\\test.dll'], + ['c:\\Windows\\test test.dll', '"c:\\Windows\\test test.dll"'], + ]; + + for (let i = 0; i < testCases.length; i++) { + const t = testCases[i]; + expect(quotePath(t[0])).toBe(t[1]); + expect(unquotePath(quotePath(t[0]))).toBe(t[0]); + } + })); + + it('should extract executable path from command', (async () => { + const testCases = [ + ['', ''], + ['/my/cmd -some -args', '/my/cmd'], + ['"/my/cmd" -some -args', '"/my/cmd"'], + ['"/my/cmd"', '"/my/cmd"'], + ['"/my/cmd and space" -some -flags', '"/my/cmd and space"'], + ['"" -some -flags', '""'], + ]; + + for (let i = 0; i < testCases.length; i++) { + const t = testCases[i]; + expect(extractExecutablePath(t[0])).toBe(t[1]); + } + })); + + it('should create correct fileURL syntax', (async () => { + const testCases_win32 = [ + ['C:\\handle\\space test', 'file:///C:/handle/space%20test'], + ['C:\\escapeplus\\+', 'file:///C:/escapeplus/%2B'], + ['C:\\handle\\single quote\'', 'file:///C:/handle/single%20quote%27'], + ]; + const testCases_unixlike = [ + ['/handle/space test', 'file:///handle/space%20test'], + ['/escapeplus/+', 'file:///escapeplus/%2B'], + ['/handle/single quote\'', 'file:///handle/single%20quote%27'], + ]; + + for (let i = 0; i < testCases_win32.length; i++) { + const t = testCases_win32[i]; + expect(toFileProtocolPath(t[0], 'win32')).toBe(t[1]); + } + for (let i = 0; i < testCases_unixlike.length; i++) { + const t = testCases_unixlike[i]; + expect(toFileProtocolPath(t[0], 'linux')).toBe(t[1]); + } + })); +}); diff --git a/packages/utils/path.ts b/packages/utils/path.ts new file mode 100644 index 0000000000..d6eccd579c --- /dev/null +++ b/packages/utils/path.ts @@ -0,0 +1,134 @@ +/* eslint no-useless-escape: 0*/ + +export function dirname(path: string) { + if (!path) throw new Error('Path is empty'); + const s = path.split(/\/|\\/); + s.pop(); + return s.join('/'); +} + +export function basename(path: string) { + if (!path) throw new Error('Path is empty'); + const s = path.split(/\/|\\/); + return s[s.length - 1]; +} + +export function filename(path: string, includeDir = false) { + if (!path) throw new Error('Path is empty'); + const output = includeDir ? path : basename(path); + if (output.indexOf('.') < 0) return output; + + const splitted = output.split('.'); + splitted.pop(); + return splitted.join('.'); +} + +export function fileExtension(path: string) { + if (!path) throw new Error('Path is empty'); + + const output = path.split('.'); + if (output.length <= 1) return ''; + return output[output.length - 1]; +} + +export function isHidden(path: string) { + const b = basename(path); + if (!b.length) throw new Error(`Path empty or not a valid path: ${path}`); + return b[0] === '.'; +} + +// Note that this function only sanitizes a file extension - it does NOT extract +// the file extension from a filename. So the way you'd normally call this is +// `safeFileExtension(fileExtension(filename))` +export function safeFileExtension(e: string, maxLength: number|null = null) { + // In theory the file extension can have any length but in practice Joplin + // expects a fixed length, so we limit it to 20 which should cover most cases. + // Note that it means that a file extension longer than 20 will break + // external editing (since the extension would be truncated). + // https://discourse.joplinapp.org/t/troubles-with-webarchive-files-on-ios/10447 + if (maxLength === null) maxLength = 20; + if (!e || !e.replace) return ''; + return e.replace(/[^a-zA-Z0-9]/g, '').substring(0, maxLength); +} + +export function safeFilename(e: string, maxLength: number|null = null, allowSpaces = false) { + if (maxLength === null) maxLength = 32; + if (!e || !e.replace) return ''; + const regex = allowSpaces ? /[^a-zA-Z0-9\-_\(\)\. ]/g : /[^a-zA-Z0-9\-_\(\)\.]/g; + const output = e.replace(regex, '_'); + return output.substring(0, maxLength); +} + +export function toFileProtocolPath(filePathEncode: string, os: string|null = null) { + if (os === null) os = process.platform; + + if (os === 'win32') { + filePathEncode = filePathEncode.replace(/\\/g, '/'); // replace backslash in windows pathname with slash e.g. c:\temp to c:/temp + filePathEncode = `/${filePathEncode}`; // put slash in front of path to comply with windows fileURL syntax + } + + filePathEncode = encodeURI(filePathEncode); + filePathEncode = filePathEncode.replace(/\+/g, '%2B'); // escape '+' with unicode + return `file://${filePathEncode.replace(/\'/g, '%27')}`; // escape '(single quote) with unicode, to prevent crashing the html view +} + +export function toSystemSlashes(path: string, os: string|null = null) { + if (os === null) os = process.platform; + if (os === 'win32') return path.replace(/\//g, '\\'); + return path.replace(/\\/g, '/'); +} + +export function toForwardSlashes(path: string) { + return toSystemSlashes(path, 'linux'); +} + +export function rtrimSlashes(path: string) { + return path.replace(/[\/\\]+$/, ''); +} + +export function ltrimSlashes(path: string) { + return path.replace(/^\/+/, ''); +} + +export function trimSlashes(path: string): string { + return ltrimSlashes(rtrimSlashes(path)); +} + +export function quotePath(path: string) { + if (!path) return ''; + if (path.indexOf('"') < 0 && path.indexOf(' ') < 0) return path; + path = path.replace(/"/, '\\"'); + return `"${path}"`; +} + +export function unquotePath(path: string) { + if (!path.length) return ''; + if (path.length && path[0] === '"') { + path = path.substring(1, path.length - 1); + } + path = path.replace(/\\"/, '"'); + return path; +} + +export function extractExecutablePath(cmd: string) { + if (!cmd.length) return ''; + + const quoteType = ['"', '\''].indexOf(cmd[0]) >= 0 ? cmd[0] : ''; + + let output = ''; + for (let i = 0; i < cmd.length; i++) { + const c = cmd[i]; + if (quoteType) { + if (i > 0 && c === quoteType) { + output += c; + break; + } + } else { + if (c === ' ') break; + } + + output += c; + } + + return output; +}