diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index 3f484608..ed26dc2b 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -323,6 +323,7 @@ class MarkdownEditor extends React.Component { storageKey, noteKey, linesHighlighted, + getNote, RTL } = this.props @@ -426,6 +427,8 @@ class MarkdownEditor extends React.Component { customCSS={config.preview.customCSS} allowCustomCSS={config.preview.allowCustomCSS} lineThroughCheckbox={config.preview.lineThroughCheckbox} + getNote={getNote} + export={config.export} onDrop={e => this.handleDropImage(e)} RTL={RTL} /> diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index 2584effb..4d263319 100755 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -18,258 +18,30 @@ import convertModeName from 'browser/lib/convertModeName' import copy from 'copy-to-clipboard' import mdurl from 'mdurl' import exportNote from 'browser/main/lib/dataApi/exportNote' -import { escapeHtmlCharacters } from 'browser/lib/utils' +import formatMarkdown from 'browser/main/lib/dataApi/formatMarkdown' +import formatHTML, { + CSS_FILES, + buildStyle, + getCodeThemeLink, + getStyleParams, + escapeHtmlCharactersInCodeTag +} from 'browser/main/lib/dataApi/formatHTML' +import formatPDF from 'browser/main/lib/dataApi/formatPDF' import yaml from 'js-yaml' +import i18n from 'browser/lib/i18n' +import path from 'path' +import { remote, shell } from 'electron' +import attachmentManagement from '../main/lib/dataApi/attachmentManagement' +import filenamify from 'filenamify' import { render } from 'react-dom' import Carousel from 'react-image-carousel' import { push } from 'connected-react-router' import ConfigManager from '../main/lib/ConfigManager' import uiThemes from 'browser/lib/ui-themes' -import i18n from 'browser/lib/i18n' - -const { remote, shell } = require('electron') -const attachmentManagement = require('../main/lib/dataApi/attachmentManagement') -const buildMarkdownPreviewContextMenu = require('browser/lib/contextMenuBuilder') - .buildMarkdownPreviewContextMenu - -const { app } = remote -const path = require('path') -const fileUrl = require('file-url') +import { buildMarkdownPreviewContextMenu } from 'browser/lib/contextMenuBuilder' const dialog = remote.dialog -const markdownStyle = require('!!css!stylus?sourceMap!./markdown.styl')[0][1] -const appPath = fileUrl( - process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve() -) -const CSS_FILES = [ - `${appPath}/node_modules/katex/dist/katex.min.css`, - `${appPath}/node_modules/codemirror/lib/codemirror.css`, - `${appPath}/node_modules/react-image-carousel/lib/css/main.min.css` -] - -/** - * @param {Object} opts - * @param {String} opts.fontFamily - * @param {Numberl} opts.fontSize - * @param {String} opts.codeBlockFontFamily - * @param {String} opts.theme - * @param {Boolean} [opts.lineNumber] Should show line number - * @param {Boolean} [opts.scrollPastEnd] - * @param {Boolean} [opts.allowCustomCSS] Should add custom css - * @param {String} [opts.customCSS] Will be added to bottom, only if `opts.allowCustomCSS` is truthy - * @returns {String} - */ -function buildStyle(opts) { - const { - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS, - RTL - } = opts - return ` -@font-face { - font-family: 'Lato'; - src: url('${appPath}/resources/fonts/Lato-Regular.woff2') format('woff2'), /* Modern Browsers */ - url('${appPath}/resources/fonts/Lato-Regular.woff') format('woff'), /* Modern Browsers */ - url('${appPath}/resources/fonts/Lato-Regular.ttf') format('truetype'); - font-style: normal; - font-weight: normal; - text-rendering: optimizeLegibility; -} -@font-face { - font-family: 'Lato'; - src: url('${appPath}/resources/fonts/Lato-Black.woff2') format('woff2'), /* Modern Browsers */ - url('${appPath}/resources/fonts/Lato-Black.woff') format('woff'), /* Modern Browsers */ - url('${appPath}/resources/fonts/Lato-Black.ttf') format('truetype'); - font-style: normal; - font-weight: 700; - text-rendering: optimizeLegibility; -} -@font-face { - font-family: 'Material Icons'; - font-style: normal; - font-weight: 400; - src: local('Material Icons'), - local('MaterialIcons-Regular'), - url('${appPath}/resources/fonts/MaterialIcons-Regular.woff2') format('woff2'), - url('${appPath}/resources/fonts/MaterialIcons-Regular.woff') format('woff'), - url('${appPath}/resources/fonts/MaterialIcons-Regular.ttf') format('truetype'); -} - -${markdownStyle} - -body { - font-family: '${fontFamily.join("','")}'; - font-size: ${fontSize}px; - - ${ - scrollPastEnd - ? ` - padding-bottom: 90vh; - box-sizing: border-box; - ` - : '' - } - ${RTL ? 'direction: rtl;' : ''} - ${RTL ? 'text-align: right;' : ''} -} -@media print { - body { - padding-bottom: initial; - } -} -code { - font-family: '${codeBlockFontFamily.join("','")}'; - background-color: rgba(0,0,0,0.04); - text-align: left; - direction: ltr; -} - -p code, -li code, -td code -{ - padding: 2px; - border-width: 1px; - border-style: solid; - border-radius: 5px; -} -[data-theme="default"] p code, -[data-theme="default"] li code, -[data-theme="default"] td code -{ - background-color: #F4F4F4; - border-color: #d9d9d9; - color: inherit; -} -[data-theme="white"] p code, -[data-theme="white"] li code, -[data-theme="white"] td code -{ - background-color: #F4F4F4; - border-color: #d9d9d9; - color: inherit; -} -[data-theme="dark"] p code, -[data-theme="dark"] li code, -[data-theme="dark"] td code -{ - background-color: #444444; - border-color: #555; - color: #FFFFFF; -} -[data-theme="dracula"] p code, -[data-theme="dracula"] li code, -[data-theme="dracula"] td code -{ - background-color: #444444; - border-color: #555; - color: #FFFFFF; -} -[data-theme="monokai"] p code, -[data-theme="monokai"] li code, -[data-theme="monokai"] td code -{ - background-color: #444444; - border-color: #555; - color: #FFFFFF; -} -[data-theme="nord"] p code, -[data-theme="nord"] li code, -[data-theme="nord"] td code -{ - background-color: #444444; - border-color: #555; - color: #FFFFFF; -} -[data-theme="solarized-dark"] p code, -[data-theme="solarized-dark"] li code, -[data-theme="solarized-dark"] td code -{ - background-color: #444444; - border-color: #555; - color: #FFFFFF; -} -[data-theme="vulcan"] p code, -[data-theme="vulcan"] li code, -[data-theme="vulcan"] td code -{ - background-color: #444444; - border-color: #555; - color: #FFFFFF; -} - -.lineNumber { - ${lineNumber && 'display: block !important;'} - font-family: '${codeBlockFontFamily.join("','")}'; -} - -.clipboardButton { - color: rgba(147,147,149,0.8);; - fill: rgba(147,147,149,1);; - border-radius: 50%; - margin: 0px 10px; - border: none; - background-color: transparent; - outline: none; - height: 15px; - width: 15px; - cursor: pointer; -} - -.clipboardButton:hover { - transition: 0.2s; - color: #939395; - fill: #939395; - background-color: rgba(0,0,0,0.1); -} - -h1, h2 { - border: none; -} - -h3 { - margin: 1em 0 0.8em; -} - -h4, h5, h6 { - margin: 1.1em 0 0.5em; -} - -h1 { - padding: 0.2em 0 0.2em; - margin: 1em 0 8px; -} - -h2 { - padding: 0.2em 0 0.2em; - margin: 1em 0 0.7em; -} - -body p { - white-space: normal; -} - -@media print { - body[data-theme="${theme}"] { - color: #000; - background-color: #fff; - } - .clipboardButton { - display: none - } -} - -${allowCustomCSS ? customCSS : ''} -` -} - const scrollBarStyle = ` ::-webkit-scrollbar { ${config.get().ui.showScrollBar ? '' : 'display: none;'} @@ -301,22 +73,6 @@ const scrollBarDarkStyle = ` } ` -const OSX = global.process.platform === 'darwin' - -const defaultFontFamily = ['helvetica', 'arial', 'sans-serif'] -if (!OSX) { - defaultFontFamily.unshift('Microsoft YaHei') - defaultFontFamily.unshift('meiryo') -} -const defaultCodeBlockFontFamily = [ - 'Monaco', - 'Menlo', - 'Ubuntu Mono', - 'Consolas', - 'source-code-pro', - 'monospace' -] - // return the line number of the line that used to generate the specified element // return -1 if the line is not found function getSourceLineNumberByElement(element) { @@ -430,94 +186,15 @@ class MarkdownPreview extends React.Component { } handleSaveAsMd() { - this.exportAsDocument('md') - } - - htmlContentFormatter(noteContent, exportTasks, targetDir) { - const { - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - codeBlockTheme, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS, - RTL - } = this.getStyleParams() - - const inlineStyles = buildStyle({ - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS, - RTL - }) - let body = this.refs.root.contentWindow.document.body.innerHTML - body = attachmentManagement.fixLocalURLS(body, this.props.storagePath) - const files = [this.getCodeThemeLink(codeBlockTheme), ...CSS_FILES] - files.forEach(file => { - if (global.process.platform === 'win32') { - file = file.replace('file:///', '') - } else { - file = file.replace('file://', '') - } - exportTasks.push({ - src: file, - dst: 'css' - }) - }) - - let styles = '' - files.forEach(file => { - styles += `` - }) - - return ` - - - - - - ${styles} - - ${body} - ` + this.exportAsDocument('md', formatMarkdown(this.props)) } handleSaveAsHtml() { - this.exportAsDocument('html', (noteContent, exportTasks, targetDir) => - Promise.resolve( - this.htmlContentFormatter(noteContent, exportTasks, targetDir) - ) - ) + this.exportAsDocument('html', formatHTML(this.props)) } handleSaveAsPdf() { - this.exportAsDocument('pdf', (noteContent, exportTasks, targetDir) => { - const printout = new remote.BrowserWindow({ - show: false, - webPreferences: { webSecurity: false, javascript: false } - }) - printout.loadURL( - 'data:text/html;charset=UTF-8,' + - this.htmlContentFormatter(noteContent, exportTasks, targetDir) - ) - return new Promise((resolve, reject) => { - printout.webContents.on('did-finish-load', () => { - printout.webContents.printToPDF({}, (err, data) => { - if (err) reject(err) - else resolve(data) - printout.destroy() - }) - }) - }) - }) + this.exportAsDocument('pdf', formatPDF(this.props)) } handlePrint() { @@ -525,18 +202,21 @@ class MarkdownPreview extends React.Component { } exportAsDocument(fileType, contentFormatter) { + const note = this.props.getNote() + const options = { + defaultPath: filenamify(note.title, { + replacement: '_' + }), filters: [{ name: 'Documents', extensions: [fileType] }], properties: ['openFile', 'createDirectory'] } dialog.showSaveDialog(remote.getCurrentWindow(), options, filename => { if (filename) { - const content = this.props.value - const storage = this.props.storagePath - const nodeKey = this.props.noteKey + const storagePath = this.props.storagePath - exportNote(nodeKey, storage, content, filename, contentFormatter) + exportNote(storagePath, note, filename, contentFormatter) .then(res => { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'info', @@ -567,32 +247,6 @@ class MarkdownPreview extends React.Component { } } - /** - * @description Convert special characters between three ``` - * @param {string[]} splitWithCodeTag Array of HTML strings separated by three ``` - * @returns {string} HTML in which special characters between three ``` have been converted - */ - escapeHtmlCharactersInCodeTag(splitWithCodeTag) { - for (let index = 0; index < splitWithCodeTag.length; index++) { - const codeTagRequired = - splitWithCodeTag[index] !== '```' && index < splitWithCodeTag.length - 1 - if (codeTagRequired) { - splitWithCodeTag.splice(index + 1, 0, '```') - } - } - let inCodeTag = false - let result = '' - for (let content of splitWithCodeTag) { - if (content === '```') { - inCodeTag = !inCodeTag - } else if (inCodeTag) { - content = escapeHtmlCharacters(content) - } - result += content - } - return result - } - getScrollBarStyle() { const { theme } = this.props @@ -743,47 +397,6 @@ class MarkdownPreview extends React.Component { } } - getStyleParams() { - const { - fontSize, - lineNumber, - codeBlockTheme, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS, - RTL - } = this.props - let { fontFamily, codeBlockFontFamily } = this.props - fontFamily = - _.isString(fontFamily) && fontFamily.trim().length > 0 - ? fontFamily - .split(',') - .map(fontName => fontName.trim()) - .concat(defaultFontFamily) - : defaultFontFamily - codeBlockFontFamily = - _.isString(codeBlockFontFamily) && codeBlockFontFamily.trim().length > 0 - ? codeBlockFontFamily - .split(',') - .map(fontName => fontName.trim()) - .concat(defaultCodeBlockFontFamily) - : defaultCodeBlockFontFamily - - return { - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - codeBlockTheme, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS, - RTL - } - } - applyStyle() { const { fontFamily, @@ -796,12 +409,13 @@ class MarkdownPreview extends React.Component { allowCustomCSS, customCSS, RTL - } = this.getStyleParams() + } = getStyleParams(this.props) this.getWindow().document.getElementById( 'codeTheme' - ).href = this.getCodeThemeLink(codeBlockTheme) - this.getWindow().document.getElementById('style').innerHTML = buildStyle({ + ).href = getCodeThemeLink(codeBlockTheme) + + this.getWindow().document.getElementById('style').innerHTML = buildStyle( fontFamily, fontSize, codeBlockFontFamily, @@ -811,15 +425,7 @@ class MarkdownPreview extends React.Component { allowCustomCSS, customCSS, RTL - }) - } - - getCodeThemeLink(name) { - const theme = consts.THEMES.find(theme => theme.name === name) - - return theme != null - ? theme.path - : `${appPath}/node_modules/codemirror/theme/elegant.css` + ) } rewriteIframe() { @@ -853,7 +459,7 @@ class MarkdownPreview extends React.Component { this.refs.root.contentWindow.document.body.setAttribute('data-theme', theme) if (sanitize === 'NONE') { const splitWithCodeTag = value.split('```') - value = this.escapeHtmlCharactersInCodeTag(splitWithCodeTag) + value = escapeHtmlCharactersInCodeTag(splitWithCodeTag) } const renderedHTML = this.markdown.render(value) attachmentManagement.migrateAttachments(value, storagePath, noteKey) @@ -916,13 +522,9 @@ class MarkdownPreview extends React.Component { }) } ) + const opts = {} - // if (this.props.theme === 'dark') { - // opts['font-color'] = '#DDD' - // opts['line-color'] = '#DDD' - // opts['element-color'] = '#DDD' - // opts['fill'] = '#3A404C' - // } + _.forEach( this.refs.root.contentWindow.document.querySelectorAll('.flowchart'), el => { diff --git a/browser/components/MarkdownSplitEditor.js b/browser/components/MarkdownSplitEditor.js index 790fecc2..f95c8f48 100644 --- a/browser/components/MarkdownSplitEditor.js +++ b/browser/components/MarkdownSplitEditor.js @@ -336,6 +336,7 @@ class MarkdownSplitEditor extends React.Component { storageKey, noteKey, linesHighlighted, + getNote, isStacking, RTL } = this.props @@ -470,6 +471,7 @@ class MarkdownSplitEditor extends React.Component { codeBlockTheme={config.preview.codeBlockTheme} codeBlockFontFamily={config.editor.fontFamily} lineNumber={config.preview.lineNumber} + indentSize={editorIndentSize} scrollPastEnd={config.preview.scrollPastEnd} smartQuotes={config.preview.smartQuotes} smartArrows={config.preview.smartArrows} @@ -486,6 +488,8 @@ class MarkdownSplitEditor extends React.Component { customCSS={config.preview.customCSS} allowCustomCSS={config.preview.allowCustomCSS} lineThroughCheckbox={config.preview.lineThroughCheckbox} + getNote={getNote} + export={config.export} RTL={RTL} /> diff --git a/browser/lib/markdown.js b/browser/lib/markdown.js index 29a3b70b..b7fda847 100644 --- a/browser/lib/markdown.js +++ b/browser/lib/markdown.js @@ -31,7 +31,8 @@ class Markdown { html: true, xhtmlOut: true, breaks: config.preview.breaks, - sanitize: 'STRICT' + sanitize: 'STRICT', + onFence: () => {} } const updatedOptions = Object.assign(defaultOptions, options) @@ -266,22 +267,26 @@ class Markdown { token.parameters.format = 'yaml' } + updatedOptions.onFence('chart', token.parameters.format) + return `
-          ${token.fileName}
-          
${ + ${token.fileName} +
${ token.content }
-
` + ` }, flowchart: token => { + updatedOptions.onFence('flowchart') + return `
-          ${token.fileName}
-          
${ + ${token.fileName} +
${ token.content }
-
` + ` }, gallery: token => { const content = token.content @@ -298,35 +303,41 @@ class Markdown { .join('\n') return `
-          ${token.fileName}
-          
-        
` + ${token.fileName} + + ` }, mermaid: token => { + updatedOptions.onFence('mermaid') + return `
-          ${token.fileName}
-          
${ + ${token.fileName} +
${ token.content }
-
` + ` }, sequence: token => { + updatedOptions.onFence('sequence') + return `
-          ${token.fileName}
-          
${ + ${token.fileName} +
${ token.content }
-
` + ` } }, token => { + updatedOptions.onFence('code', token.langType) + return `
-        ${token.fileName}
-        ${createGutter(token.content, token.firstLineNumber)}
-        ${token.content}
-      
` + ${token.fileName} + ${createGutter(token.content, token.firstLineNumber)} + ${token.content} + ` } ) diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js index 9007989e..be76e35c 100755 --- a/browser/main/Detail/MarkdownNoteDetail.js +++ b/browser/main/Detail/MarkdownNoteDetail.js @@ -57,10 +57,11 @@ class MarkdownNoteDetail extends React.Component { this.dispatchTimer = null - this.toggleLockButton = this.handleToggleLockButton.bind(this) this.generateToc = this.handleGenerateToc.bind(this) + this.toggleLockButton = this.handleToggleLockButton.bind(this) this.handleUpdateContent = this.handleUpdateContent.bind(this) this.handleSwitchStackDirection = this.handleSwitchStackDirection.bind(this) + this.getNote = this.getNote.bind(this) } focus() { @@ -441,6 +442,10 @@ class MarkdownNoteDetail extends React.Component { this.updateNote(note) } + getNote() { + return this.state.note + } + renderEditor() { const { config, ignorePreviewPointerEvents } = this.props const { note, isStacking } = this.state @@ -456,8 +461,8 @@ class MarkdownNoteDetail extends React.Component { noteKey={note.key} linesHighlighted={note.linesHighlighted} onChange={this.handleUpdateContent} - isLocked={this.state.isLocked} ignorePreviewPointerEvents={ignorePreviewPointerEvents} + getNote={this.getNote} RTL={config.editor.rtlEnabled && this.state.RTL} /> ) @@ -473,6 +478,7 @@ class MarkdownNoteDetail extends React.Component { linesHighlighted={note.linesHighlighted} onChange={this.handleUpdateContent} ignorePreviewPointerEvents={ignorePreviewPointerEvents} + getNote={this.getNote} RTL={config.editor.rtlEnabled && this.state.RTL} /> ) diff --git a/browser/main/NoteList/index.js b/browser/main/NoteList/index.js index 2722bc3b..51dfe0d8 100644 --- a/browser/main/NoteList/index.js +++ b/browser/main/NoteList/index.js @@ -21,6 +21,7 @@ import Markdown from '../../lib/markdown' import i18n from 'browser/lib/i18n' import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' import context from 'browser/lib/context' +import filenamify from 'filenamify' import queryString from 'query-string' const { remote } = require('electron') @@ -634,6 +635,38 @@ class NoteList extends React.Component { this.selectNextNote() } + handleExportClick(e, note, fileType) { + const options = { + defaultPath: filenamify(note.title, { + replacement: '_' + }), + filters: [{ name: 'Documents', extensions: [fileType] }], + properties: ['openFile', 'createDirectory'] + } + + dialog.showSaveDialog(remote.getCurrentWindow(), options, filename => { + if (filename) { + const { config } = this.props + + dataApi + .exportNoteAs(note, filename, fileType, config) + .then(res => { + dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message: `Exported to ${filename}` + }) + }) + .catch(err => { + dialog.showErrorBox( + 'Export error', + err ? err.message || err : 'Unexpected error during export' + ) + throw err + }) + } + }) + } + handleNoteContextMenu(e, uniqueKey) { const { location } = this.props const { selectedNoteKeys } = this.state @@ -689,9 +722,40 @@ class NoteList extends React.Component { click: this.copyNoteLink.bind(this, note) } ) + if (note.type === 'MARKDOWN_NOTE') { + templates.push( + { + type: 'separator' + }, + { + label: i18n.__('Export Note'), + submenu: [ + { + label: i18n.__('Export as Plain Text (.txt)'), + click: e => this.handleExportClick(e, note, 'txt') + }, + { + label: i18n.__('Export as Markdown (.md)'), + click: e => this.handleExportClick(e, note, 'md') + }, + { + label: i18n.__('Export as HTML (.html)'), + click: e => this.handleExportClick(e, note, 'html') + }, + { + label: i18n.__('Export as PDF (.pdf)'), + click: e => this.handleExportClick(e, note, 'pdf') + } + ] + } + ) + if (note.blog && note.blog.blogLink && note.blog.blogId) { templates.push( + { + type: 'separator' + }, { label: updateLabel, click: this.publishMarkdown.bind(this) @@ -702,10 +766,15 @@ class NoteList extends React.Component { } ) } else { - templates.push({ - label: publishLabel, - click: this.publishMarkdown.bind(this) - }) + templates.push( + { + type: 'separator' + }, + { + label: publishLabel, + click: this.publishMarkdown.bind(this) + } + ) } } } diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index a152fc00..eeef3056 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -43,12 +43,20 @@ class StorageItem extends React.Component { label: i18n.__('Export Storage'), submenu: [ { - label: i18n.__('Export as txt'), + label: i18n.__('Export as Plain Text (.txt)'), click: e => this.handleExportStorageClick(e, 'txt') }, { - label: i18n.__('Export as md'), + label: i18n.__('Export as Markdown (.md)'), click: e => this.handleExportStorageClick(e, 'md') + }, + { + label: i18n.__('Export as HTML (.html)'), + click: e => this.handleExportStorageClick(e, 'html') + }, + { + label: i18n.__('Export as PDF (.pdf)'), + click: e => this.handleExportStorageClick(e, 'pdf') } ] }, @@ -97,14 +105,28 @@ class StorageItem extends React.Component { } dialog.showOpenDialog(remote.getCurrentWindow(), options, paths => { if (paths && paths.length === 1) { - const { storage, dispatch } = this.props - dataApi.exportStorage(storage.key, fileType, paths[0]).then(data => { - dispatch({ - type: 'EXPORT_STORAGE', - storage: data.storage, - fileType: data.fileType + const { storage, dispatch, config } = this.props + dataApi + .exportStorage(storage.key, fileType, paths[0], config) + .then(data => { + dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message: `Exported to ${paths[0]}` + }) + + dispatch({ + type: 'EXPORT_STORAGE', + storage: data.storage, + fileType: data.fileType + }) + }) + .catch(error => { + dialog.showErrorBox( + 'Export error', + error ? error.message || error : 'Unexpected error during export' + ) + throw error }) - }) } }) } @@ -166,12 +188,20 @@ class StorageItem extends React.Component { label: i18n.__('Export Folder'), submenu: [ { - label: i18n.__('Export as txt'), + label: i18n.__('Export as Plain Text (.txt)'), click: e => this.handleExportFolderClick(e, folder, 'txt') }, { - label: i18n.__('Export as md'), + label: i18n.__('Export as Markdown (.md)'), click: e => this.handleExportFolderClick(e, folder, 'md') + }, + { + label: i18n.__('Export as HTML (.html)'), + click: e => this.handleExportFolderClick(e, folder, 'html') + }, + { + label: i18n.__('Export as PDF (.pdf)'), + click: e => this.handleExportFolderClick(e, folder, 'pdf') } ] }, @@ -202,30 +232,28 @@ class StorageItem extends React.Component { } dialog.showOpenDialog(remote.getCurrentWindow(), options, paths => { if (paths && paths.length === 1) { - const { storage, dispatch } = this.props + const { storage, dispatch, config } = this.props dataApi - .exportFolder(storage.key, folder.key, fileType, paths[0]) + .exportFolder(storage.key, folder.key, fileType, paths[0], config) .then(data => { + dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message: `Exported to ${paths[0]}` + }) + dispatch({ type: 'EXPORT_FOLDER', storage: data.storage, folderKey: data.folderKey, fileType: data.fileType }) - return data }) - .then(data => { - dialog.showMessageBox(remote.getCurrentWindow(), { - type: 'info', - message: 'Exported to "' + data.exportDir + '"' - }) - }) - .catch(err => { + .catch(error => { dialog.showErrorBox( 'Export error', - err ? err.message || err : 'Unexpected error during export' + error ? error.message || error : 'Unexpected error during export' ) - throw err + throw error }) } }) diff --git a/browser/main/SideNav/index.js b/browser/main/SideNav/index.js index c2f04116..a8f0489d 100644 --- a/browser/main/SideNav/index.js +++ b/browser/main/SideNav/index.js @@ -26,6 +26,8 @@ import { confirmDeleteNote } from 'browser/lib/confirmDeleteNote' import ColorPicker from 'browser/components/ColorPicker' import { every, sortBy } from 'lodash' +const { dialog } = remote + function matchActiveTags(tags, activeTags) { return every(activeTags, v => tags.indexOf(v) >= 0) } @@ -63,15 +65,12 @@ class SideNav extends React.Component { } deleteTag(tag) { - const selectedButton = remote.dialog.showMessageBox( - remote.getCurrentWindow(), - { - type: 'warning', - message: i18n.__('Confirm tag deletion'), - detail: i18n.__('This will permanently remove this tag.'), - buttons: [i18n.__('Confirm'), i18n.__('Cancel')] - } - ) + const selectedButton = dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'warning', + message: i18n.__('Confirm tag deletion'), + detail: i18n.__('This will permanently remove this tag.'), + buttons: [i18n.__('Confirm'), i18n.__('Cancel')] + }) if (selectedButton === 0) { const { @@ -155,28 +154,80 @@ class SideNav extends React.Component { } handleTagContextMenu(e, tag) { - const menu = [] + context.popup([ + { + label: i18n.__('Rename Tag'), + click: this.handleRenameTagClick.bind(this, tag) + }, + { + label: i18n.__('Customize Color'), + click: this.displayColorPicker.bind( + this, + tag, + e.target.getBoundingClientRect() + ) + }, + { + type: 'separator' + }, + { + label: i18n.__('Export Tag'), + submenu: [ + { + label: i18n.__('Export as Plain Text (.txt)'), + click: e => this.handleExportTagClick(e, tag, 'txt') + }, + { + label: i18n.__('Export as Markdown (.md)'), + click: e => this.handleExportTagClick(e, tag, 'md') + }, + { + label: i18n.__('Export as HTML (.html)'), + click: e => this.handleExportTagClick(e, tag, 'html') + }, + { + label: i18n.__('Export as PDF (.pdf)'), + click: e => this.handleExportTagClick(e, tag, 'pdf') + } + ] + }, + { + type: 'separator' + }, + { + label: i18n.__('Delete Tag'), + click: this.deleteTag.bind(this, tag) + } + ]) + } - menu.push({ - label: i18n.__('Delete Tag'), - click: this.deleteTag.bind(this, tag) + handleExportTagClick(e, tag, fileType) { + const options = { + properties: ['openDirectory', 'createDirectory'], + buttonLabel: i18n.__('Select directory'), + title: i18n.__('Select a folder to export the files to'), + multiSelections: false + } + dialog.showOpenDialog(remote.getCurrentWindow(), options, paths => { + if (paths && paths.length === 1) { + const { data, config } = this.props + dataApi + .exportTag(data, tag, fileType, paths[0], config) + .then(data => { + dialog.showMessageBox(remote.getCurrentWindow(), { + type: 'info', + message: `Exported to ${paths[0]}` + }) + }) + .catch(error => { + dialog.showErrorBox( + 'Export error', + error ? error.message || error : 'Unexpected error during export' + ) + throw error + }) + } }) - - menu.push({ - label: i18n.__('Customize Color'), - click: this.displayColorPicker.bind( - this, - tag, - e.target.getBoundingClientRect() - ) - }) - - menu.push({ - label: i18n.__('Rename Tag'), - click: this.handleRenameTagClick.bind(this, tag) - }) - - context.popup(menu) } dismissColorPicker() { @@ -330,6 +381,7 @@ class SideNav extends React.Component { dispatch={dispatch} onSortEnd={this.onSortEnd.bind(this)(storage)} useDragHandle + config={config} /> ) }) diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index 2e931f00..4356fd01 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -144,6 +144,11 @@ export const DEFAULT_CONFIG = { username: '', password: '' }, + export: { + metadata: 'DONT_EXPORT', // 'DONT_EXPORT', 'MERGE_HEADER', 'MERGE_VARIABLE' + variable: 'boostnote', + prefixAttachmentFolder: false + }, coloredTags: {}, wakatime: { key: null diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js index 48500f4a..33e00b0b 100644 --- a/browser/main/lib/dataApi/attachmentManagement.js +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -706,14 +706,15 @@ function replaceNoteKeyWithNewNoteKey(noteContent, oldNoteKey, newNoteKey) { } /** - * @description Deletes all :storage and noteKey references from the given input. - * @param input Input in which the references should be deleted + * @description replace all :storage references with given destination folder. + * @param input Input in which the references should be replaced * @param noteKey Key of the current note + * @param destinationFolder Destination folder of the attachements * @returns {String} Input without the references */ -function removeStorageAndNoteReferences(input, noteKey) { +function replaceStorageReferences(input, noteKey, destinationFolder) { return input.replace( - new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '.*?("|\\))', 'g'), + new RegExp('/?' + STORAGE_FOLDER_PLACEHOLDER + '[^"\\)<\\s]+', 'g'), function(match) { return match .replace(new RegExp(mdurl.encode(path.win32.sep), 'g'), path.posix.sep) @@ -735,7 +736,7 @@ function removeStorageAndNoteReferences(input, noteKey) { ')?', 'g' ), - DESTINATION_FOLDER + destinationFolder ) } ) @@ -1101,8 +1102,8 @@ module.exports = { getAttachmentsInMarkdownContent, getAbsolutePathsOfAttachmentsInContent, importAttachments, - removeStorageAndNoteReferences, removeAttachmentsByPaths, + replaceStorageReferences, deleteAttachmentFolder, deleteAttachmentsNotPresentInNote, getAttachmentsPathAndStatus, diff --git a/browser/main/lib/dataApi/copyFile.js b/browser/main/lib/dataApi/copyFile.js index 0b390b2a..70459376 100755 --- a/browser/main/lib/dataApi/copyFile.js +++ b/browser/main/lib/dataApi/copyFile.js @@ -1,5 +1,6 @@ -const fs = require('fs') -const path = require('path') +import fs from 'fs' +import fx from 'fs-extra' +import path from 'path' /** * @description Copy a file from source to destination @@ -14,7 +15,8 @@ function copyFile(srcPath, dstPath) { return new Promise((resolve, reject) => { const dstFolder = path.dirname(dstPath) - if (!fs.existsSync(dstFolder)) fs.mkdirSync(dstFolder) + + fx.ensureDirSync(dstFolder) const input = fs.createReadStream(decodeURI(srcPath)) const output = fs.createWriteStream(dstPath) diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js index a77ba29b..6ac56abf 100644 --- a/browser/main/lib/dataApi/exportFolder.js +++ b/browser/main/lib/dataApi/exportFolder.js @@ -1,15 +1,16 @@ import { findStorage } from 'browser/lib/findStorage' import resolveStorageData from './resolveStorageData' import resolveStorageNotes from './resolveStorageNotes' +import getFilename from './getFilename' import exportNote from './exportNote' -import filenamify from 'filenamify' -import * as path from 'path' +import getContentFormatter from './getContentFormatter' /** * @param {String} storageKey * @param {String} folderKey * @param {String} fileType * @param {String} exportDir + * @param {Object} config * * @return {Object} * ``` @@ -22,7 +23,7 @@ import * as path from 'path' * ``` */ -function exportFolder(storageKey, folderKey, fileType, exportDir) { +function exportFolder(storageKey, folderKey, fileType, exportDir, config) { let targetStorage try { targetStorage = findStorage(storageKey) @@ -30,39 +31,34 @@ function exportFolder(storageKey, folderKey, fileType, exportDir) { return Promise.reject(e) } + const deduplicator = {} + return resolveStorageData(targetStorage) - .then(function assignNotes(storage) { - return resolveStorageNotes(storage).then(notes => { - return { - storage, - notes - } - }) + .then(storage => { + return resolveStorageNotes(storage).then(notes => ({ + storage, + notes: notes.filter( + note => + note.folder === folderKey && + !note.isTrashed && + note.type === 'MARKDOWN_NOTE' + ) + })) }) - .then(function exportNotes(data) { - const { storage, notes } = data + .then(({ storage, notes }) => { + const contentFormatter = getContentFormatter(storage, fileType, config) return Promise.all( - notes - .filter( - note => - note.folder === folderKey && - note.isTrashed === false && - note.type === 'MARKDOWN_NOTE' + notes.map(note => { + const targetPath = getFilename( + note, + fileType, + exportDir, + deduplicator ) - .map(note => { - const notePath = path.join( - exportDir, - `${filenamify(note.title, { replacement: '_' })}.${fileType}` - ) - return exportNote( - note.key, - storage.path, - note.content, - notePath, - null - ) - }) + + return exportNote(storage.key, note, targetPath, contentFormatter) + }) ).then(() => ({ storage, folderKey, diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js index ffd45a1c..a1003268 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -4,58 +4,35 @@ import { findStorage } from 'browser/lib/findStorage' const fs = require('fs') const path = require('path') -const attachmentManagement = require('./attachmentManagement') - /** * Export note together with attachments * * If attachments are stored in the storage, creates 'attachments' subfolder in target directory * and copies attachments to it. Changes links to images in the content of the note * - * @param {String} nodeKey key of the node that should be exported * @param {String} storageKey or storage path - * @param {String} noteContent Content to export + * @param {Object} note Note to export * @param {String} targetPath Path to exported file * @param {function} outputFormatter * @return {Promise.<*[]>} */ -function exportNote( - nodeKey, - storageKey, - noteContent, - targetPath, - outputFormatter -) { +function exportNote(storageKey, note, targetPath, outputFormatter) { const storagePath = path.isAbsolute(storageKey) ? storageKey : findStorage(storageKey).path + const exportTasks = [] if (!storagePath) { throw new Error('Storage path is not found') } - const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( - noteContent, - storagePath - ) - attachmentsAbsolutePaths.forEach(attachment => { - exportTasks.push({ - src: attachment, - dst: attachmentManagement.DESTINATION_FOLDER - }) - }) - let exportedData = attachmentManagement.removeStorageAndNoteReferences( - noteContent, - nodeKey + const exportedData = Promise.resolve( + outputFormatter + ? outputFormatter(note, targetPath, exportTasks) + : note.content ) - if (outputFormatter) { - exportedData = outputFormatter(exportedData, exportTasks, targetPath) - } else { - exportedData = Promise.resolve(exportedData) - } - const tasks = prepareTasks(exportTasks, storagePath, path.dirname(targetPath)) return Promise.all(tasks.map(task => copyFile(task.src, task.dst))) @@ -63,9 +40,9 @@ function exportNote( .then(data => { return saveToFile(data, targetPath) }) - .catch(err => { + .catch(error => { rollbackExport(tasks) - throw err + throw error }) } @@ -107,14 +84,14 @@ function rollbackExport(tasks) { } if (fs.existsSync(fullpath)) { - fs.unlink(fullpath) + fs.unlinkSync(fullpath) folders.add(path.dirname(fullpath)) } }) folders.forEach(folder => { if (fs.readdirSync(folder).length === 0) { - fs.rmdir(folder) + fs.rmdirSync(folder) } }) } diff --git a/browser/main/lib/dataApi/exportNoteAs.js b/browser/main/lib/dataApi/exportNoteAs.js new file mode 100644 index 00000000..e884ae89 --- /dev/null +++ b/browser/main/lib/dataApi/exportNoteAs.js @@ -0,0 +1,19 @@ +import { findStorage } from 'browser/lib/findStorage' +import exportNote from './exportNote' +import getContentFormatter from './getContentFormatter' + +/** + * @param {Object} note + * @param {String} filename + * @param {String} fileType + * @param {Object} config + */ + +function exportNoteAs(note, filename, fileType, config) { + const storage = findStorage(note.storage) + const contentFormatter = getContentFormatter(storage, fileType, config) + + return exportNote(storage.key, note, filename, contentFormatter) +} + +module.exports = exportNoteAs diff --git a/browser/main/lib/dataApi/exportStorage.js b/browser/main/lib/dataApi/exportStorage.js index 2a7c725c..525e13cf 100644 --- a/browser/main/lib/dataApi/exportStorage.js +++ b/browser/main/lib/dataApi/exportStorage.js @@ -2,13 +2,17 @@ import { findStorage } from 'browser/lib/findStorage' import resolveStorageData from './resolveStorageData' import resolveStorageNotes from './resolveStorageNotes' import filenamify from 'filenamify' -import * as path from 'path' -import * as fs from 'fs' +import path from 'path' +import fs from 'fs' +import exportNote from './exportNote' +import getContentFormatter from './getContentFormatter' +import getFilename from './getFilename' /** * @param {String} storageKey * @param {String} fileType * @param {String} exportDir + * @param {Object} config * * @return {Object} * ``` @@ -20,7 +24,7 @@ import * as fs from 'fs' * ``` */ -function exportStorage(storageKey, fileType, exportDir) { +function exportStorage(storageKey, fileType, exportDir, config) { let targetStorage try { targetStorage = findStorage(storageKey) @@ -29,39 +33,52 @@ function exportStorage(storageKey, fileType, exportDir) { } return resolveStorageData(targetStorage) - .then(storage => - resolveStorageNotes(storage).then(notes => ({ storage, notes })) - ) - .then(function exportNotes(data) { - const { storage, notes } = data + .then(storage => { + return resolveStorageNotes(storage).then(notes => ({ + storage, + notes: notes.filter( + note => !note.isTrashed && note.type === 'MARKDOWN_NOTE' + ) + })) + }) + .then(({ storage, notes }) => { + const contentFormatter = getContentFormatter(storage, fileType, config) + const folderNamesMapping = {} + const deduplicators = {} + storage.folders.forEach(folder => { const folderExportedDir = path.join( exportDir, filenamify(folder.name, { replacement: '_' }) ) + folderNamesMapping[folder.key] = folderExportedDir + // make sure directory exists try { fs.mkdirSync(folderExportedDir) } catch (e) {} - }) - notes - .filter(note => !note.isTrashed && note.type === 'MARKDOWN_NOTE') - .forEach(markdownNote => { - const folderExportedDir = folderNamesMapping[markdownNote.folder] - const snippetName = `${filenamify(markdownNote.title, { - replacement: '_' - })}.${fileType}` - const notePath = path.join(folderExportedDir, snippetName) - fs.writeFileSync(notePath, markdownNote.content) - }) - return { + deduplicators[folder.key] = {} + }) + + return Promise.all( + notes.map(note => { + const targetPath = getFilename( + note, + fileType, + folderNamesMapping[note.folder], + deduplicators[note.folder] + ) + + return exportNote(storage.key, note, targetPath, contentFormatter) + }) + ).then(() => ({ storage, fileType, exportDir - } + })) }) } diff --git a/browser/main/lib/dataApi/exportTag.js b/browser/main/lib/dataApi/exportTag.js new file mode 100644 index 00000000..0bef11b9 --- /dev/null +++ b/browser/main/lib/dataApi/exportTag.js @@ -0,0 +1,28 @@ +import exportNoteAs from './exportNoteAs' +import getFilename from './getFilename' + +/** + * @param {Object} data + * @param {String} tag + * @param {String} fileType + * @param {String} exportDir + * @param {Object} config + */ + +function exportTag(data, tag, fileType, exportDir, config) { + const notes = data.noteMap + .map(note => note) + .filter(note => note.tags.indexOf(tag) !== -1) + + const deduplicator = {} + + return Promise.all( + notes.map(note => { + const filename = getFilename(note, fileType, exportDir, deduplicator) + + return exportNoteAs(note, filename, fileType, config) + }) + ) +} + +module.exports = exportTag diff --git a/browser/main/lib/dataApi/formatHTML.js b/browser/main/lib/dataApi/formatHTML.js new file mode 100644 index 00000000..37c3756c --- /dev/null +++ b/browser/main/lib/dataApi/formatHTML.js @@ -0,0 +1,796 @@ +import path from 'path' +import fileUrl from 'file-url' +import fs from 'fs' +import { remote } from 'electron' +import consts from 'browser/lib/consts' +import Markdown from 'browser/lib/markdown' +import attachmentManagement from './attachmentManagement' +import { version as codemirrorVersion } from 'codemirror/package.json' +import { escapeHtmlCharacters } from 'browser/lib/utils' + +const { app } = remote +const appPath = fileUrl( + process.env.NODE_ENV === 'production' ? app.getAppPath() : path.resolve() +) + +let markdownStyle = '' +try { + markdownStyle = require('!!css!stylus?sourceMap!../../../components/markdown.styl')[0][1] +} catch (e) {} + +export const CSS_FILES = [ + `${appPath}/node_modules/katex/dist/katex.min.css`, + `${appPath}/node_modules/codemirror/lib/codemirror.css`, + `${appPath}/node_modules/react-image-carousel/lib/css/main.min.css` +] + +const macos = global.process.platform === 'darwin' + +const defaultFontFamily = ['helvetica', 'arial', 'sans-serif'] +if (!macos) { + defaultFontFamily.unshift('Microsoft YaHei') + defaultFontFamily.unshift('meiryo') +} + +const defaultCodeBlockFontFamily = [ + 'Monaco', + 'Menlo', + 'Ubuntu Mono', + 'Consolas', + 'source-code-pro', + 'monospace' +] + +function unprefix(file) { + if (global.process.platform === 'win32') { + return file.replace('file:///', '') + } else { + return file.replace('file://', '') + } +} + +/** + * ``` + * { + * fontFamily, + * fontSize, + * lineNumber, + * codeBlockFontFamily, + * codeBlockTheme, + * scrollPastEnd, + * theme, + * allowCustomCSS, + * customCSS + * smartQuotes, + * sanitize, + * breaks, + * storagePath, + * export, + * indentSize + * } + * ``` + */ +export default function formatHTML(props) { + const { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = getStyleParams(props) + + const inlineStyles = buildStyle( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + ) + + const { smartQuotes, sanitize, breaks } = props + + let indentSize = parseInt(props.indentSize, 10) + if (!(indentSize > 0 && indentSize < 132)) { + indentSize = 4 + } + + const files = [getCodeThemeLink(codeBlockTheme), ...CSS_FILES] + + return function(note, targetPath, exportTasks) { + const styles = files + .map(file => ``) + .join('\n') + + let inlineScripts = '' + let scripts = '' + + let decodeEntities = false + function addDecodeEntities() { + if (decodeEntities) { + return + } + + decodeEntities = true + + inlineScripts += ` +function decodeEntities (text) { + var entities = [ + ['apos', '\\''], + ['amp', '&'], + ['lt', '<'], + ['gt', '>'], + ['#63', '\\?'], + ['#36', '\\$'] + ] + + for (var i = 0, max = entities.length; i < max; ++i) { + text = text.replace(new RegExp(\`&\${entities[i][0]};\`, 'g'), entities[i][1]) + } + + return text +}` + } + + let lodash = false + function addLodash() { + if (lodash) { + return + } + + lodash = true + + exportTasks.push({ + src: unprefix(`${appPath}/node_modules/lodash/lodash.min.js`), + dst: 'js' + }) + + scripts += `` + } + + let raphael = false + function addRaphael() { + if (raphael) { + return + } + + raphael = true + + exportTasks.push({ + src: unprefix(`${appPath}/node_modules/raphael/raphael.min.js`), + dst: 'js' + }) + + scripts += `` + } + + let yaml = false + function addYAML() { + if (yaml) { + return + } + + yaml = true + + exportTasks.push({ + src: unprefix(`${appPath}/node_modules/js-yaml/dist/js-yaml.min.js`), + dst: 'js' + }) + + scripts += `` + } + + let chart = false + function addChart() { + if (chart) { + return + } + + chart = true + + addLodash() + + exportTasks.push({ + src: unprefix(`${appPath}/node_modules/chart.js/dist/Chart.min.js`), + dst: 'js' + }) + + scripts += `` + + inlineScripts += ` +function displayCharts() { + _.forEach( + document.querySelectorAll('.chart'), + el => { + try { + const format = el.attributes.getNamedItem('data-format').value + const chartConfig = format === 'yaml' ? jsyaml.load(el.innerHTML) : JSON.parse(el.innerHTML) + el.innerHTML = '' + + const canvas = document.createElement('canvas') + el.appendChild(canvas) + + const height = el.attributes.getNamedItem('data-height') + if (height && height.value !== 'undefined') { + el.style.height = height.value + 'vh' + canvas.height = height.value + 'vh' + } + + const chart = new Chart(canvas, chartConfig) + } catch (e) { + el.className = 'chart-error' + el.innerHTML = 'chartjs diagram parse error: ' + e.message + } + } + ) +} + +document.addEventListener('DOMContentLoaded', displayCharts); +` + } + + let codemirror = false + function addCodeMirror() { + if (codemirror) { + return + } + + codemirror = true + + addDecodeEntities() + addLodash() + + exportTasks.push( + { + src: unprefix(`${appPath}/node_modules/codemirror/lib/codemirror.js`), + dst: 'js/codemirror' + }, + { + src: unprefix(`${appPath}/node_modules/codemirror/mode/meta.js`), + dst: 'js/codemirror/mode' + }, + { + src: unprefix( + `${appPath}/node_modules/codemirror/addon/mode/loadmode.js` + ), + dst: 'js/codemirror/addon/mode' + }, + { + src: unprefix( + `${appPath}/node_modules/codemirror/addon/runmode/runmode.js` + ), + dst: 'js/codemirror/addon/runmode' + } + ) + + scripts += ` + + + + +` + + let className = `cm-s-${codeBlockTheme}` + if (codeBlockTheme.indexOf('solarized') === 0) { + const [refThema, color] = codeBlockTheme.split(' ') + className = `cm-s-${refThema} cm-s-${color}` + } + + inlineScripts += ` +CodeMirror.modeURL = 'https://cdn.jsdelivr.net/npm/codemirror@${codemirrorVersion}/mode/%N/%N.js'; + +function displayCodeBlocks() { + _.forEach( + document.querySelectorAll('.code code'), + el => { + el.parentNode.className += ' ${className}' + let syntax = CodeMirror.findModeByName(el.className) + if (syntax == null) syntax = CodeMirror.findModeByName('Plain Text') + CodeMirror.requireMode(syntax.mode, () => { + const content = decodeEntities(el.innerHTML) + el.innerHTML = '' + CodeMirror.runMode(content, syntax.mime, el, { + tabSize: ${indentSize} + }) + }) + } + ) +} + +document.addEventListener('DOMContentLoaded', displayCodeBlocks); +` + } + + let flowchart = false + function addFlowchart() { + if (flowchart) { + return + } + + flowchart = true + + addDecodeEntities() + addLodash() + addRaphael() + + exportTasks.push({ + src: unprefix( + `${appPath}/node_modules/flowchart.js/release/flowchart.min.js` + ), + dst: 'js' + }) + + scripts += `` + + inlineScripts += ` +function displayFlowcharts() { + _.forEach( + document.querySelectorAll('.flowchart'), + el => { + try { + const diagram = flowchart.parse( + decodeEntities(el.innerHTML) + ) + el.innerHTML = '' + diagram.drawSVG(el) + } catch (e) { + el.className = 'flowchart-error' + el.innerHTML = 'Flowchart parse error: ' + e.message + } + } + ) +} + +document.addEventListener('DOMContentLoaded', displayFlowcharts); +` + } + + let mermaid = false + function addMermaid() { + if (mermaid) { + return + } + + mermaid = true + + addLodash() + + exportTasks.push({ + src: unprefix(`${appPath}/node_modules/mermaid/dist/mermaid.min.js`), + dst: 'js' + }) + + scripts += `` + + inlineScripts += ` +function displayMermaids() { + _.forEach( + document.querySelectorAll('.mermaid'), + el => { + const height = el.attributes.getNamedItem('data-height') + if (height && height.value !== 'undefined') { + el.style.height = height.value + 'vh' + } + } + ) +} + +document.addEventListener('DOMContentLoaded', displayMermaids); +` + } + + let sequence = false + function addSequence() { + if (sequence) { + return + } + + sequence = true + + addDecodeEntities() + addLodash() + addRaphael() + + exportTasks.push({ + src: unprefix( + `${appPath}/node_modules/@rokt33r/js-sequence-diagrams/dist/sequence-diagram-min.js` + ), + dst: 'js' + }) + + scripts += `` + + inlineScripts += ` +function displaySequences() { + _.forEach( + document.querySelectorAll('.sequence'), + el => { + try { + const diagram = Diagram.parse( + decodeEntities(el.innerHTML) + ) + el.innerHTML = '' + diagram.drawSVG(el, { theme: 'simple' }) + } catch (e) { + el.className = 'sequence-error' + el.innerHTML = 'Sequence diagram parse error: ' + e.message + } + } + ) +} + +document.addEventListener('DOMContentLoaded', displaySequences); +` + } + + const modes = {} + const markdown = new Markdown({ + typographer: smartQuotes, + sanitize, + breaks, + onFence(type, mode) { + if (type === 'chart') { + addChart() + + if (mode === 'yaml') { + addYAML() + } + } else if (type === 'code') { + addCodeMirror() + + if (mode && modes[mode] !== true) { + const file = unprefix( + `${appPath}/node_modules/codemirror/mode/${mode}/${mode}.js` + ) + + if (fs.existsSync(file)) { + exportTasks.push({ + src: file, + dst: `js/codemirror/mode/${mode}` + }) + + modes[mode] = true + } + } + } else if (type === 'flowchart') { + addFlowchart() + } else if (type === 'mermaid') { + addMermaid() + } else if (type === 'sequence') { + addSequence() + } + } + }) + + let body = note.content + + if (sanitize === 'NONE') { + body = escapeHtmlCharactersInCodeTag(body.split('```')) + } + + body = markdown.render(note.content) + + const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( + note.content, + props.storagePath + ) + + files.forEach(file => { + exportTasks.push({ + src: unprefix(file), + dst: 'css' + }) + }) + + const destinationFolder = props.export.prefixAttachmentFolder + ? `${path.parse(targetPath).name} - ${ + attachmentManagement.DESTINATION_FOLDER + }` + : attachmentManagement.DESTINATION_FOLDER + + attachmentsAbsolutePaths.forEach(attachment => { + exportTasks.push({ + src: attachment, + dst: destinationFolder + }) + }) + + body = attachmentManagement.replaceStorageReferences( + body, + note.key, + destinationFolder + ) + + return ` + + + + + + ${styles} + ${scripts} + + + +${body} + + +` + } +} + +export function getStyleParams(props) { + const { + fontSize, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS, + RTL + } = props + + let { fontFamily, codeBlockFontFamily } = props + + fontFamily = + _.isString(fontFamily) && fontFamily.trim().length > 0 + ? fontFamily + .split(',') + .map(fontName => fontName.trim()) + .concat(defaultFontFamily) + : defaultFontFamily + + codeBlockFontFamily = + _.isString(codeBlockFontFamily) && codeBlockFontFamily.trim().length > 0 + ? codeBlockFontFamily + .split(',') + .map(fontName => fontName.trim()) + .concat(defaultCodeBlockFontFamily) + : defaultCodeBlockFontFamily + + return { + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS, + RTL + } +} + +export function getCodeThemeLink(name) { + const theme = consts.THEMES.find(theme => theme.name === name) + + return theme != null + ? theme.path + : `${appPath}/node_modules/codemirror/theme/elegant.css` +} + +export function buildStyle( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS, + RTL +) { + return ` +@font-face { + font-family: 'Lato'; + src: url('${appPath}/resources/fonts/Lato-Regular.woff2') format('woff2'), /* Modern Browsers */ + url('${appPath}/resources/fonts/Lato-Regular.woff') format('woff'), /* Modern Browsers */ + url('${appPath}/resources/fonts/Lato-Regular.ttf') format('truetype'); + font-style: normal; + font-weight: normal; + text-rendering: optimizeLegibility; +} +@font-face { + font-family: 'Lato'; + src: url('${appPath}/resources/fonts/Lato-Black.woff2') format('woff2'), /* Modern Browsers */ + url('${appPath}/resources/fonts/Lato-Black.woff') format('woff'), /* Modern Browsers */ + url('${appPath}/resources/fonts/Lato-Black.ttf') format('truetype'); + font-style: normal; + font-weight: 700; + text-rendering: optimizeLegibility; +} +@font-face { + font-family: 'Material Icons'; + font-style: normal; + font-weight: 400; + src: local('Material Icons'), + local('MaterialIcons-Regular'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.woff2') format('woff2'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.woff') format('woff'), + url('${appPath}/resources/fonts/MaterialIcons-Regular.ttf') format('truetype'); +} +${markdownStyle} + +body { + font-family: '${fontFamily.join("','")}'; + font-size: ${fontSize}px; + ${scrollPastEnd && 'padding-bottom: 90vh;box-sizing: border-box;'} + ${RTL && 'direction: rtl;text-align: right;'} +} +@media print { + body { + padding-bottom: initial; + } +} + +code { + font-family: '${codeBlockFontFamily.join("','")}'; + background-color: rgba(0,0,0,0.04); + text-align: left; + direction: ltr; +} + +p code, +li code, +td code +{ + padding: 2px; + border-width: 1px; + border-style: solid; + border-radius: 5px; +} +[data-theme="default"] p code, +[data-theme="default"] li code, +[data-theme="default"] td code +{ + background-color: #F4F4F4; + border-color: #d9d9d9; + color: inherit; +} +[data-theme="white"] p code, +[data-theme="white"] li code, +[data-theme="white"] td code +{ + background-color: #F4F4F4; + border-color: #d9d9d9; + color: inherit; +} +[data-theme="dark"] p code, +[data-theme="dark"] li code, +[data-theme="dark"] td code +{ + background-color: #444444; + border-color: #555; + color: #FFFFFF; +} +[data-theme="dracula"] p code, +[data-theme="dracula"] li code, +[data-theme="dracula"] td code +{ + background-color: #444444; + border-color: #555; + color: #FFFFFF; +} +[data-theme="monokai"] p code, +[data-theme="monokai"] li code, +[data-theme="monokai"] td code +{ + background-color: #444444; + border-color: #555; + color: #FFFFFF; +} +[data-theme="nord"] p code, +[data-theme="nord"] li code, +[data-theme="nord"] td code +{ + background-color: #444444; + border-color: #555; + color: #FFFFFF; +} +[data-theme="solarized-dark"] p code, +[data-theme="solarized-dark"] li code, +[data-theme="solarized-dark"] td code +{ + background-color: #444444; + border-color: #555; + color: #FFFFFF; +} +[data-theme="vulcan"] p code, +[data-theme="vulcan"] li code, +[data-theme="vulcan"] td code +{ + background-color: #444444; + border-color: #555; + color: #FFFFFF; +} + +.lineNumber { + ${lineNumber && 'display: block !important;'} + font-family: '${codeBlockFontFamily.join("','")}'; +} + +.clipboardButton { + color: rgba(147,147,149,0.8);; + fill: rgba(147,147,149,1);; + border-radius: 50%; + margin: 0px 10px; + border: none; + background-color: transparent; + outline: none; + height: 15px; + width: 15px; + cursor: pointer; +} + +.clipboardButton:hover { + transition: 0.2s; + color: #939395; + fill: #939395; + background-color: rgba(0,0,0,0.1); +} + +h1, h2 { + border: none; +} + +h1 { + padding-bottom: 4px; + margin: 1em 0 8px; +} + +h2 { + padding-bottom: 0.2em; + margin: 1em 0 0.37em; +} + +body p { + white-space: normal; +} + +@media print { + body[data-theme="${theme}"] { + color: #000; + background-color: #fff; + } + .clipboardButton { + display: none + } +} + +${allowCustomCSS ? customCSS : ''} +` +} + +/** + * @description Convert special characters between three ``` + * @param {string[]} splitWithCodeTag Array of HTML strings separated by three ``` + * @returns {string} HTML in which special characters between three ``` have been converted + */ +export function escapeHtmlCharactersInCodeTag(splitWithCodeTag) { + for (let index = 0; index < splitWithCodeTag.length; index++) { + const codeTagRequired = + splitWithCodeTag[index] !== '```' && index < splitWithCodeTag.length - 1 + if (codeTagRequired) { + splitWithCodeTag.splice(index + 1, 0, '```') + } + } + let inCodeTag = false + let result = '' + for (let content of splitWithCodeTag) { + if (content === '```') { + inCodeTag = !inCodeTag + } else if (inCodeTag) { + content = escapeHtmlCharacters(content) + } + result += content + } + return result +} diff --git a/browser/main/lib/dataApi/formatMarkdown.js b/browser/main/lib/dataApi/formatMarkdown.js new file mode 100644 index 00000000..e9dcd471 --- /dev/null +++ b/browser/main/lib/dataApi/formatMarkdown.js @@ -0,0 +1,103 @@ +import attachmentManagement from './attachmentManagement' +import yaml from 'js-yaml' +import path from 'path' + +const delimiterRegExp = /^\-{3}/ + +/** + * ``` + * { + * storagePath, + * export + * } + * ``` + */ +export default function formatMarkdown(props) { + return function(note, targetPath, exportTasks) { + let result = note.content + + if (props.storagePath && note.key) { + const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( + result, + props.storagePath + ) + + const destinationFolder = props.export.prefixAttachmentFolder + ? `${path.parse(targetPath).name} - ${ + attachmentManagement.DESTINATION_FOLDER + }` + : attachmentManagement.DESTINATION_FOLDER + + attachmentsAbsolutePaths.forEach(attachment => { + exportTasks.push({ + src: attachment, + dst: destinationFolder + }) + }) + + result = attachmentManagement.replaceStorageReferences( + result, + note.key, + destinationFolder + ) + } + + if (props.export.metadata === 'MERGE_HEADER') { + const metadata = getFrontMatter(result) + + const values = Object.assign({}, note) + delete values.content + delete values.isTrashed + + for (const key in values) { + metadata[key] = values[key] + } + + result = replaceFrontMatter(result, metadata) + } else if (props.export.metadata === 'MERGE_VARIABLE') { + const metadata = getFrontMatter(result) + + const values = Object.assign({}, note) + delete values.content + delete values.isTrashed + + if (props.export.variable) { + metadata[props.export.variable] = values + } else { + for (const key in values) { + metadata[key] = values[key] + } + } + + result = replaceFrontMatter(result, metadata) + } + + return result + } +} + +function getFrontMatter(markdown) { + const lines = markdown.split('\n') + + if (delimiterRegExp.test(lines[0])) { + let line = 0 + while (++line < lines.length && !delimiterRegExp.test(lines[line])) {} + + return yaml.load(lines.slice(1, line).join('\n')) || {} + } else { + return {} + } +} + +function replaceFrontMatter(markdown, metadata) { + const lines = markdown.split('\n') + + if (delimiterRegExp.test(lines[0])) { + let line = 0 + while (++line < lines.length && !delimiterRegExp.test(lines[line])) {} + + return `---\n${yaml.dump(metadata)}---\n${lines.slice(line + 1).join('\n')}` + } else { + return `---\n${yaml.dump(metadata)}---\n\n${markdown}` + } +} diff --git a/browser/main/lib/dataApi/formatPDF.js b/browser/main/lib/dataApi/formatPDF.js new file mode 100644 index 00000000..a852dc90 --- /dev/null +++ b/browser/main/lib/dataApi/formatPDF.js @@ -0,0 +1,26 @@ +import formatHTML from './formatHTML' +import { remote } from 'electron' + +export default function formatPDF(props) { + return function(note, targetPath, exportTasks) { + const printout = new remote.BrowserWindow({ + show: false, + webPreferences: { webSecurity: false, javascript: false } + }) + + printout.loadURL( + 'data:text/html;charset=UTF-8,' + + formatHTML(props)(note, targetPath, exportTasks) + ) + + return new Promise((resolve, reject) => { + printout.webContents.on('did-finish-load', () => { + printout.webContents.printToPDF({}, (err, data) => { + if (err) reject(err) + else resolve(data) + printout.destroy() + }) + }) + }) + } +} diff --git a/browser/main/lib/dataApi/getContentFormatter.js b/browser/main/lib/dataApi/getContentFormatter.js new file mode 100644 index 00000000..b39b681c --- /dev/null +++ b/browser/main/lib/dataApi/getContentFormatter.js @@ -0,0 +1,58 @@ +import formatMarkdown from './formatMarkdown' +import formatHTML from './formatHTML' +import formatPDF from './formatPDF' + +/** + * @param {Object} storage + * @param {String} fileType + * @param {Object} config + */ + +export default function getContentFormatter(storage, fileType, config) { + if (fileType === 'md') { + return formatMarkdown({ + storagePath: storage.path, + export: config.export + }) + } else if (fileType === 'html') { + return formatHTML({ + theme: config.ui.theme, + fontSize: config.preview.fontSize, + fontFamily: config.preview.fontFamily, + codeBlockTheme: config.preview.codeBlockTheme, + codeBlockFontFamily: config.editor.fontFamily, + lineNumber: config.preview.lineNumber, + indentSize: config.editor.indentSize, + scrollPastEnd: config.preview.scrollPastEnd, + smartQuotes: config.preview.smartQuotes, + breaks: config.preview.breaks, + sanitize: config.preview.sanitize, + customCSS: config.preview.customCSS, + allowCustomCSS: config.preview.allowCustomCSS, + storagePath: storage.path, + export: config.export, + RTL: config.editor.rtlEnabled /* && this.state.RTL */ + }) + } else if (fileType === 'pdf') { + return formatPDF({ + theme: config.ui.theme, + fontSize: config.preview.fontSize, + fontFamily: config.preview.fontFamily, + codeBlockTheme: config.preview.codeBlockTheme, + codeBlockFontFamily: config.editor.fontFamily, + lineNumber: config.preview.lineNumber, + indentSize: config.editor.indentSize, + scrollPastEnd: config.preview.scrollPastEnd, + smartQuotes: config.preview.smartQuotes, + breaks: config.preview.breaks, + sanitize: config.preview.sanitize, + customCSS: config.preview.customCSS, + allowCustomCSS: config.preview.allowCustomCSS, + storagePath: storage.path, + export: config.export, + RTL: config.editor.rtlEnabled /* && this.state.RTL */ + }) + } + + return null +} diff --git a/browser/main/lib/dataApi/getFilename.js b/browser/main/lib/dataApi/getFilename.js new file mode 100644 index 00000000..59947954 --- /dev/null +++ b/browser/main/lib/dataApi/getFilename.js @@ -0,0 +1,37 @@ +import filenamify from 'filenamify' +import i18n from 'browser/lib/i18n' +import path from 'path' + +/** + * @param {Object} note + * @param {String} fileType + * @param {String} directory + * @param {Object} deduplicator + * + * @return {String} + */ + +function getFilename(note, fileType, directory, deduplicator) { + const basename = note.title + ? filenamify(note.title, { replacement: '_' }) + : i18n.__('Untitled') + + if (deduplicator) { + if (deduplicator[basename]) { + const filename = path.join( + directory, + `${basename} (${deduplicator[basename]}).${fileType}` + ) + + ++deduplicator[basename] + + return filename + } else { + deduplicator[basename] = 1 + } + } + + return path.join(directory, `${basename}.${fileType}`) +} + +module.exports = getFilename diff --git a/browser/main/lib/dataApi/index.js b/browser/main/lib/dataApi/index.js index 6e88bbf9..5bc85126 100644 --- a/browser/main/lib/dataApi/index.js +++ b/browser/main/lib/dataApi/index.js @@ -15,11 +15,14 @@ const dataApi = { updateNote: require('./updateNote'), deleteNote: require('./deleteNote'), moveNote: require('./moveNote'), + exportNoteAs: require('./exportNoteAs'), migrateFromV5Storage: require('./migrateFromV5Storage'), createSnippet: require('./createSnippet'), deleteSnippet: require('./deleteSnippet'), updateSnippet: require('./updateSnippet'), fetchSnippet: require('./fetchSnippet'), + exportTag: require('./exportTag'), + getFilename: require('./getFilename'), _migrateFromV6Storage: require('./migrateFromV6Storage'), _resolveStorageData: require('./resolveStorageData'), diff --git a/browser/main/modals/PreferencesModal/ExportTab.js b/browser/main/modals/PreferencesModal/ExportTab.js new file mode 100644 index 00000000..57a4de4d --- /dev/null +++ b/browser/main/modals/PreferencesModal/ExportTab.js @@ -0,0 +1,184 @@ +import PropTypes from 'prop-types' +import React from 'react' +import CSSModules from 'browser/lib/CSSModules' +import styles from './ConfigTab.styl' +import ConfigManager from 'browser/main/lib/ConfigManager' +import store from 'browser/main/store' +import _ from 'lodash' +import i18n from 'browser/lib/i18n' + +const electron = require('electron') +const ipc = electron.ipcRenderer + +class ExportTab extends React.Component { + constructor(props) { + super(props) + + this.state = { + config: props.config + } + } + + clearMessage() { + _.debounce(() => { + this.setState({ + ExportAlert: null + }) + }, 2000)() + } + + componentDidMount() { + this.handleSettingDone = () => { + this.setState({ + ExportAlert: { + type: 'success', + message: i18n.__('Successfully applied!') + } + }) + } + this.handleSettingError = err => { + this.setState({ + ExportAlert: { + type: 'error', + message: + err.message != null ? err.message : i18n.__('An error occurred!') + } + }) + } + + this.oldExport = this.state.config.export + + ipc.addListener('APP_SETTING_DONE', this.handleSettingDone) + ipc.addListener('APP_SETTING_ERROR', this.handleSettingError) + } + + componentWillUnmount() { + ipc.removeListener('APP_SETTING_DONE', this.handleSettingDone) + ipc.removeListener('APP_SETTING_ERROR', this.handleSettingError) + } + + handleSaveButtonClick(e) { + const newConfig = { + export: this.state.config.export + } + + ConfigManager.set(newConfig) + + store.dispatch({ + type: 'SET_UI', + config: newConfig + }) + + this.clearMessage() + this.props.haveToSave() + } + + handleExportChange(e) { + const { config } = this.state + + config.export = { + metadata: this.refs.metadata.value, + variable: !_.isNil(this.refs.variable) + ? this.refs.variable.value + : config.export.variable, + prefixAttachmentFolder: this.refs.prefixAttachmentFolder.checked + } + + this.setState({ + config + }) + + if (_.isEqual(this.oldExport, config.export)) { + this.props.haveToSave() + } else { + this.props.haveToSave({ + tab: 'Export', + type: 'warning', + message: i18n.__('Unsaved Changes!') + }) + } + } + + render() { + const { config, ExportAlert } = this.state + + const ExportAlertElement = + ExportAlert != null ? ( +

{ExportAlert.message}

+ ) : null + + return ( +
+
+
{i18n.__('Export')}
+ +
+
{i18n.__('Metadata')}
+
+ +
+
+ + {config.export.metadata === 'MERGE_VARIABLE' && ( +
+
+ {i18n.__('Variable Name')} +
+
+ this.handleExportChange(e)} + ref='variable' + value={config.export.variable} + type='text' + /> +
+
+ )} + +
+ +
+ +
+ + {ExportAlertElement} +
+
+
+ ) + } +} + +ExportTab.propTypes = { + dispatch: PropTypes.func, + haveToSave: PropTypes.func +} + +export default CSSModules(ExportTab, styles) diff --git a/browser/main/modals/PreferencesModal/index.js b/browser/main/modals/PreferencesModal/index.js index 36abd734..80062e59 100644 --- a/browser/main/modals/PreferencesModal/index.js +++ b/browser/main/modals/PreferencesModal/index.js @@ -6,6 +6,7 @@ import UiTab from './UiTab' import InfoTab from './InfoTab' import Crowdfunding from './Crowdfunding' import StoragesTab from './StoragesTab' +import ExportTab from './ExportTab' import SnippetTab from './SnippetTab' import PluginsTab from './PluginsTab' import Blog from './Blog' @@ -24,7 +25,8 @@ class Preferences extends React.Component { currentTab: 'STORAGES', UIAlert: '', HotkeyAlert: '', - BlogAlert: '' + BlogAlert: '', + ExportAlert: '' } } @@ -81,6 +83,15 @@ class Preferences extends React.Component { haveToSave={alert => this.setState({ BlogAlert: alert })} /> ) + case 'EXPORT': + return ( + this.setState({ ExportAlert: alert })} + /> + ) case 'SNIPPET': return case 'PLUGINS': @@ -131,6 +142,11 @@ class Preferences extends React.Component { { target: 'INFO', label: i18n.__('About') }, { target: 'CROWDFUNDING', label: i18n.__('Crowdfunding') }, { target: 'BLOG', label: i18n.__('Blog'), Blog: this.state.BlogAlert }, + { + target: 'EXPORT', + label: i18n.__('Export'), + Export: this.state.ExportAlert + }, { target: 'SNIPPET', label: i18n.__('Snippets') }, { target: 'PLUGINS', label: i18n.__('Plugins') } ] diff --git a/locales/de.json b/locales/de.json index 22f15957..1d857c90 100644 --- a/locales/de.json +++ b/locales/de.json @@ -202,7 +202,6 @@ "Create new folder": "Ordner erstellen", "Folder name": "Ordnername", "Create": "Erstellen", - "Untitled": "Neuer Ordner", "Unlink Storage": "Speicherverknüpfung aufheben", "Unlinking removes this linked storage from Boostnote. No data is removed, please manually delete the folder from your hard drive if needed.": "Die Verknüpfung des Speichers mit Boostnote wird entfernt. Es werden keine Daten gelöscht. Um die Daten dauerhaft zu löschen musst du den Ordner auf der Festplatte manuell entfernen.", "Empty note": "Leere Notiz", diff --git a/tests/dataApi/attachmentManagement.test.js b/tests/dataApi/attachmentManagement.test.js index 1bad1e89..6adee614 100644 --- a/tests/dataApi/attachmentManagement.test.js +++ b/tests/dataApi/attachmentManagement.test.js @@ -702,14 +702,15 @@ it('should remove the all ":storage" and noteKey references', function() { '

\n' + ' \n' + '' - const actual = systemUnderTest.removeStorageAndNoteReferences( + const actual = systemUnderTest.replaceStorageReferences( testInput, - noteKey + noteKey, + systemUnderTest.DESTINATION_FOLDER ) expect(actual).toEqual(expectedOutput) }) -it('should make sure that "removeStorageAndNoteReferences" works with markdown content as well', function() { +it('should make sure that "replaceStorageReferences" works with markdown content as well', function() { const noteKey = 'noteKey' const testInput = 'Test input' + @@ -736,9 +737,113 @@ it('should make sure that "removeStorageAndNoteReferences" works with markdown c systemUnderTest.DESTINATION_FOLDER + path.posix.sep + 'pdf.pdf)' - const actual = systemUnderTest.removeStorageAndNoteReferences( + const actual = systemUnderTest.replaceStorageReferences( testInput, - noteKey + noteKey, + systemUnderTest.DESTINATION_FOLDER + ) + expect(actual).toEqual(expectedOutput) +}) + +it('should replace the all ":storage" references', function() { + const storageFolder = systemUnderTest.DESTINATION_FOLDER + const noteKey = 'noteKey' + const testInput = + '\n' + + ' \n' + + ' //header\n' + + ' \n' + + ' \n' + + '

Headline

\n' + + '

\n' + + ' dummyImage.png\n' + + '

\n' + + '

\n' + + ' dummyPDF.pdf\n' + + '

\n' + + '

\n' + + ' dummyImage2.jpg\n' + + '

\n' + + ' \n' + + '' + const expectedOutput = + '\n' + + ' \n' + + ' //header\n' + + ' \n' + + ' \n' + + '

Headline

\n' + + '

\n' + + ' dummyImage.png\n' + + '

\n' + + '

\n' + + ' dummyPDF.pdf\n' + + '

\n' + + '

\n' + + ' dummyImage2.jpg\n' + + '

\n' + + ' \n' + + '' + const actual = systemUnderTest.replaceStorageReferences( + testInput, + noteKey, + systemUnderTest.DESTINATION_FOLDER + ) + expect(actual).toEqual(expectedOutput) +}) + +it('should make sure that "replaceStorageReferences" works with markdown content as well', function() { + const noteKey = 'noteKey' + const testInput = + 'Test input' + + '![imageName](' + + systemUnderTest.STORAGE_FOLDER_PLACEHOLDER + + path.win32.sep + + noteKey + + path.win32.sep + + 'image.jpg) \n' + + '[pdf](' + + systemUnderTest.STORAGE_FOLDER_PLACEHOLDER + + path.posix.sep + + noteKey + + path.posix.sep + + 'pdf.pdf)' + + const expectedOutput = + 'Test input' + + '![imageName](' + + systemUnderTest.DESTINATION_FOLDER + + path.posix.sep + + 'image.jpg) \n' + + '[pdf](' + + systemUnderTest.DESTINATION_FOLDER + + path.posix.sep + + 'pdf.pdf)' + const actual = systemUnderTest.replaceStorageReferences( + testInput, + noteKey, + systemUnderTest.DESTINATION_FOLDER ) expect(actual).toEqual(expectedOutput) }) diff --git a/tests/dataApi/exportFolder-test.js b/tests/dataApi/exportFolder-test.js index d0aef186..f6bc31e8 100644 --- a/tests/dataApi/exportFolder-test.js +++ b/tests/dataApi/exportFolder-test.js @@ -52,12 +52,20 @@ test.serial('Export a folder', t => { } input2.title = 'input2' + const config = { + export: { + metadata: 'DONT_EXPORT', + variable: 'boostnote', + prefixAttachmentFolder: false + } + } + return createNote(storageKey, input1) .then(function() { return createNote(storageKey, input2) }) .then(function() { - return exportFolder(storageKey, folderKey, 'md', storagePath) + return exportFolder(storageKey, folderKey, 'md', storagePath, config) }) .then(function assert() { let filePath = path.join(storagePath, 'input1.md') diff --git a/tests/dataApi/exportStorage-test.js b/tests/dataApi/exportStorage-test.js index 1ee26f19..5ce235f8 100644 --- a/tests/dataApi/exportStorage-test.js +++ b/tests/dataApi/exportStorage-test.js @@ -35,7 +35,16 @@ test.serial('Export a storage', t => { acc[folder.key] = folder.name return acc }, {}) - return exportStorage(storageKey, 'md', exportDir).then(() => { + + const config = { + export: { + metadata: 'DONT_EXPORT', + variable: 'boostnote', + prefixAttachmentFolder: false + } + } + + return exportStorage(storageKey, 'md', exportDir, config).then(() => { notes.forEach(note => { const noteDir = path.join( exportDir, diff --git a/tests/lib/__snapshots__/markdown.test.js.snap b/tests/lib/__snapshots__/markdown.test.js.snap index 5d04bc4c..050edc3d 100644 --- a/tests/lib/__snapshots__/markdown.test.js.snap +++ b/tests/lib/__snapshots__/markdown.test.js.snap @@ -98,11 +98,11 @@ exports[`Markdown.render() should renders checkboxes 1`] = ` exports[`Markdown.render() should renders codeblock correctly 1`] = ` "
-        filename.js
-        2
-        var project = 'boostnote';
+          filename.js
+          2
+          var project = 'boostnote';
 
-      
" + " `; exports[`Markdown.render() should renders definition lists correctly 1`] = `