diff --git a/browser/components/MarkdownEditor.js b/browser/components/MarkdownEditor.js index f3bb92bb..9e5e8f53 100644 --- a/browser/components/MarkdownEditor.js +++ b/browser/components/MarkdownEditor.js @@ -230,7 +230,7 @@ class MarkdownEditor extends React.Component { } render () { - const {className, value, config, storageKey, noteKey} = this.props + const {className, value, config, storageKey, noteKey, getNote} = this.props let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -308,6 +308,8 @@ class MarkdownEditor extends React.Component { customCSS={config.preview.customCSS} allowCustomCSS={config.preview.allowCustomCSS} lineThroughCheckbox={config.preview.lineThroughCheckbox} + getNote={getNote} + export={config.export} /> ) diff --git a/browser/components/MarkdownPreview.js b/browser/components/MarkdownPreview.js index d9ff7074..84053c4c 100755 --- a/browser/components/MarkdownPreview.js +++ b/browser/components/MarkdownPreview.js @@ -16,144 +16,21 @@ 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 formatMarkdown from 'browser/main/lib/dataApi/formatMarkdown' +import formatHTML, { CSS_FILES, buildStyle, getCodeThemeLink, getStyleParams } from 'browser/main/lib/dataApi/formatHTML' import { escapeHtmlCharacters } from 'browser/lib/utils' import yaml from 'js-yaml' import context from 'browser/lib/context' import i18n from 'browser/lib/i18n' import fs from 'fs' - -const { remote, shell } = require('electron') -const attachmentManagement = require('../main/lib/dataApi/attachmentManagement') - -const { app } = remote -const path = require('path') -const fileUrl = require('file-url') +import path from 'path' +import uri2path from 'file-uri-to-path' +import { remote, shell } from 'electron' +import attachmentManagement from '../main/lib/dataApi/attachmentManagement' +import filenamify from 'filenamify' const dialog = remote.dialog -const uri2path = require('file-uri-to-path') - -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` -] - -function buildStyle ( - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS -) { - 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;'} -} -@media print { - body { - padding-bottom: initial; - } -} -code { - font-family: '${codeBlockFontFamily.join("','")}'; - background-color: rgba(0,0,0,0.04); -} -.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 : ''} -` -} - const scrollBarStyle = ` ::-webkit-scrollbar { width: 12px; @@ -173,21 +50,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' -] export default class MarkdownPreview extends React.Component { constructor (props) { super(props) @@ -286,96 +148,11 @@ export default class MarkdownPreview extends React.Component { } handleSaveAsMd () { - this.exportAsDocument('md', (noteContent, exportTasks) => { - let result = noteContent - if (this.props && this.props.storagePath && this.props.noteKey) { - const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( - noteContent, - this.props.storagePath - ) - attachmentsAbsolutePaths.forEach(attachment => { - exportTasks.push({ - src: attachment, - dst: attachmentManagement.DESTINATION_FOLDER - }) - }) - result = attachmentManagement.removeStorageAndNoteReferences( - noteContent, - this.props.noteKey - ) - } - return result - }) + this.exportAsDocument('md', formatMarkdown(this.props)) } handleSaveAsHtml () { - this.exportAsDocument('html', (noteContent, exportTasks) => { - const { - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - codeBlockTheme, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS - } = this.getStyleParams() - - const inlineStyles = buildStyle( - fontFamily, - fontSize, - codeBlockFontFamily, - lineNumber, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS - ) - let body = this.markdown.render(noteContent) - const files = [this.GetCodeThemeLink(codeBlockTheme), ...CSS_FILES] - const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent( - noteContent, - this.props.storagePath - ) - - files.forEach(file => { - if (global.process.platform === 'win32') { - file = file.replace('file:///', '') - } else { - file = file.replace('file://', '') - } - exportTasks.push({ - src: file, - dst: 'css' - }) - }) - attachmentsAbsolutePaths.forEach(attachment => { - exportTasks.push({ - src: attachment, - dst: attachmentManagement.DESTINATION_FOLDER - }) - }) - body = attachmentManagement.removeStorageAndNoteReferences( - body, - this.props.noteKey - ) - - let styles = '' - files.forEach(file => { - styles += `` - }) - - return ` - - - - - ${styles} - - ${body} - ` - }) + this.exportAsDocument('html', formatHTML(this.props)) } handlePrint () { @@ -383,17 +160,21 @@ export default 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 storagePath = this.props.storagePath - exportNote(storage, content, filename, contentFormatter) + exportNote(storagePath, note, filename, contentFormatter) .then(res => { dialog.showMessageBox(remote.getCurrentWindow(), { type: 'info', @@ -555,44 +336,6 @@ export default class MarkdownPreview extends React.Component { } } - getStyleParams () { - const { - fontSize, - lineNumber, - codeBlockTheme, - scrollPastEnd, - theme, - allowCustomCSS, - customCSS - } = 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 - } - } - applyStyle () { const { fontFamily, @@ -604,11 +347,10 @@ export default class MarkdownPreview extends React.Component { theme, allowCustomCSS, customCSS - } = this.getStyleParams() + } = getStyleParams(this.props) + + this.getWindow().document.getElementById('codeTheme').href = getCodeThemeLink(codeBlockTheme) - this.getWindow().document.getElementById( - 'codeTheme' - ).href = this.GetCodeThemeLink(codeBlockTheme) this.getWindow().document.getElementById('style').innerHTML = buildStyle( fontFamily, fontSize, @@ -621,16 +363,6 @@ export default class MarkdownPreview extends React.Component { ) } - GetCodeThemeLink (theme) { - theme = consts.THEMES.some(_theme => _theme === theme) && - theme !== 'default' - ? theme - : 'elegant' - return theme.startsWith('solarized') - ? `${appPath}/node_modules/codemirror/theme/solarized.css` - : `${appPath}/node_modules/codemirror/theme/${theme}.css` - } - rewriteIframe () { _.forEach( this.refs.root.contentWindow.document.querySelectorAll( diff --git a/browser/components/MarkdownSplitEditor.js b/browser/components/MarkdownSplitEditor.js index 4a08bf12..b9600877 100644 --- a/browser/components/MarkdownSplitEditor.js +++ b/browser/components/MarkdownSplitEditor.js @@ -134,7 +134,7 @@ class MarkdownSplitEditor extends React.Component { } render () { - const {config, value, storageKey, noteKey} = this.props + const {config, value, storageKey, noteKey, getTitle} = this.props const storage = findStorage(storageKey) let editorFontSize = parseInt(config.editor.fontSize, 10) if (!(editorFontSize > 0 && editorFontSize < 101)) editorFontSize = 14 @@ -199,6 +199,8 @@ class MarkdownSplitEditor extends React.Component { customCSS={config.preview.customCSS} allowCustomCSS={config.preview.allowCustomCSS} lineThroughCheckbox={config.preview.lineThroughCheckbox} + getTitle={getTitle} + export={config.export} /> ) diff --git a/browser/main/Detail/MarkdownNoteDetail.js b/browser/main/Detail/MarkdownNoteDetail.js index 116fdec0..a14c389a 100755 --- a/browser/main/Detail/MarkdownNoteDetail.js +++ b/browser/main/Detail/MarkdownNoteDetail.js @@ -49,6 +49,8 @@ class MarkdownNoteDetail extends React.Component { this.toggleLockButton = this.handleToggleLockButton.bind(this) this.generateToc = () => this.handleGenerateToc() + + this.getNote = this.getNote.bind(this) } focus () { @@ -319,6 +321,10 @@ class MarkdownNoteDetail extends React.Component { this.updateNote(note) } + getNote () { + return this.state.note + } + renderEditor () { const { config, ignorePreviewPointerEvents } = this.props const { note } = this.state @@ -333,6 +339,7 @@ class MarkdownNoteDetail extends React.Component { noteKey={note.key} onChange={this.handleUpdateContent.bind(this)} ignorePreviewPointerEvents={ignorePreviewPointerEvents} + getNote={this.getNote} /> } else { return } } diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index d17314b3..7c9bdcb0 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -42,12 +42,16 @@ 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') } ] }, @@ -94,16 +98,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 - .exportStorage(storage.key, fileType, paths[0]) + .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 + }) } }) } @@ -157,12 +173,16 @@ 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') } ] }, @@ -194,10 +214,15 @@ 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]) - .then((data) => { + .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, @@ -205,6 +230,13 @@ class StorageItem extends React.Component { fileType: data.fileType }) }) + .catch(error => { + dialog.showErrorBox( + 'Export error', + error ? error.message || error : 'Unexpected error during export' + ) + throw error + }) } }) } @@ -274,7 +306,7 @@ class StorageItem extends React.Component { const { folderNoteMap, trashedSet } = data const SortableStorageItemChild = SortableElement(StorageItemChild) const folderList = storage.folders.map((folder, index) => { - let folderRegex = new RegExp(escapeStringRegexp(path.sep) + 'storages' + escapeStringRegexp(path.sep) + storage.key + escapeStringRegexp(path.sep) + 'folders' + escapeStringRegexp(path.sep) + folder.key) + const folderRegex = new RegExp(escapeStringRegexp(path.sep) + 'storages' + escapeStringRegexp(path.sep) + storage.key + escapeStringRegexp(path.sep) + 'folders' + escapeStringRegexp(path.sep) + folder.key) const isActive = !!(location.pathname.match(folderRegex)) const noteSet = folderNoteMap.get(storage.key + '-' + folder.key) diff --git a/browser/main/SideNav/index.js b/browser/main/SideNav/index.js index b98d859d..fe304347 100644 --- a/browser/main/SideNav/index.js +++ b/browser/main/SideNav/index.js @@ -347,6 +347,7 @@ class SideNav extends React.Component { dispatch={dispatch} onSortEnd={this.onSortEnd.bind(this)(storage)} useDragHandle + config={config} /> }) const style = {} diff --git a/browser/main/lib/ConfigManager.js b/browser/main/lib/ConfigManager.js index b662a892..e85b049c 100644 --- a/browser/main/lib/ConfigManager.js +++ b/browser/main/lib/ConfigManager.js @@ -82,8 +82,9 @@ export const DEFAULT_CONFIG = { password: '' }, export: { - action: 'DONT_EXPORT', // 'DONT_EXPORT', 'MERGE_HEADER', 'MERGE_VARIABLE' - variable: 'metadata' + metadata: 'DONT_EXPORT', // 'DONT_EXPORT', 'MERGE_HEADER', 'MERGE_VARIABLE' + variable: 'boostnote', + prefixAttachmentFolder: false } } diff --git a/browser/main/lib/dataApi/attachmentManagement.js b/browser/main/lib/dataApi/attachmentManagement.js index c193eaf2..980bf859 100644 --- a/browser/main/lib/dataApi/attachmentManagement.js +++ b/browser/main/lib/dataApi/attachmentManagement.js @@ -386,6 +386,17 @@ function removeStorageAndNoteReferences (input, noteKey) { return input.replace(new RegExp(mdurl.encode(path.sep), 'g'), path.sep).replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + '(' + escapeStringRegexp(path.sep) + noteKey + ')?', 'g'), DESTINATION_FOLDER) } +/** + * @description replace all :storage references with given destination folder. + * @param input Input in which the references should be deleted + * @param noteKey Key of the current note + * @param destinationFolder Destination folder of the attachements + * @returns {String} Input without the references + */ +function replaceStorageReferences (input, noteKey, destinationFolder) { + return input.replace(new RegExp(STORAGE_FOLDER_PLACEHOLDER + '(' + escapeStringRegexp(path.sep) + noteKey + ')?', 'g'), destinationFolder || DESTINATION_FOLDER) +} + /** * @description Deletes the attachment folder specified by the given storageKey and noteKey * @param storageKey Key of the storage of the note to be deleted @@ -542,6 +553,7 @@ module.exports = { getAttachmentsInMarkdownContent, getAbsolutePathsOfAttachmentsInContent, removeStorageAndNoteReferences, + replaceStorageReferences, deleteAttachmentFolder, deleteAttachmentsNotPresentInNote, moveAttachments, diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js index 3e998f15..aef7bd8e 100644 --- a/browser/main/lib/dataApi/exportFolder.js +++ b/browser/main/lib/dataApi/exportFolder.js @@ -2,14 +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 exportNote from './exportNote' +import formatMarkdown from './formatMarkdown' +import formatHTML from './formatHTML' /** * @param {String} storageKey * @param {String} folderKey * @param {String} fileType * @param {String} exportDir + * @param {Object} config * * @return {Object} * ``` @@ -22,7 +25,7 @@ import * as fs from 'fs' * ``` */ -function exportFolder (storageKey, folderKey, fileType, exportDir) { +function exportFolder (storageKey, folderKey, fileType, exportDir, config) { let targetStorage try { targetStorage = findStorage(storageKey) @@ -31,31 +34,50 @@ function exportFolder (storageKey, folderKey, fileType, exportDir) { } return resolveStorageData(targetStorage) - .then(function assignNotes (storage) { - return resolveStorageNotes(storage) - .then((notes) => { - return { - storage, - notes - } - }) - }) - .then(function exportNotes (data) { - const { storage, notes } = data - - notes - .filter(note => note.folder === folderKey && note.isTrashed === false && note.type === 'MARKDOWN_NOTE') - .forEach(snippet => { - const notePath = path.join(exportDir, `${filenamify(snippet.title, {replacement: '_'})}.${fileType}`) - fs.writeFileSync(notePath, snippet.content) - }) - - return { + .then(storage => { + return resolveStorageNotes(storage).then(notes => ({ storage, - folderKey, - fileType, - exportDir + notes: notes.filter(note => note.folder === folderKey && !note.isTrashed && note.type === 'MARKDOWN_NOTE') + })) + }) + .then(({ storage, notes }) => { + let contentFormatter = null + if (fileType === 'md') { + contentFormatter = formatMarkdown({ + storagePath: storage.path, + export: config.export + }) + } else if (fileType === 'html') { + contentFormatter = 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, + 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 + }) } + + return Promise + .all(notes.map(note => { + const targetPath = path.join(exportDir, `${filenamify(note.title, {replacement: '_'})}.${fileType}`) + + return exportNote(storage.key, note, targetPath, contentFormatter) + })) + .then(() => ({ + storage, + folderKey, + fileType, + exportDir + })) }) } diff --git a/browser/main/lib/dataApi/exportNote.js b/browser/main/lib/dataApi/exportNote.js index e4fec5f4..970528db 100755 --- a/browser/main/lib/dataApi/exportNote.js +++ b/browser/main/lib/dataApi/exportNote.js @@ -16,7 +16,7 @@ const path = require('path') * @param {function} outputFormatter * @return {Promise.<*[]>} */ -function exportNote (storageKey, noteContent, targetPath, outputFormatter) { +function exportNote (storageKey, note, targetPath, outputFormatter) { const storagePath = path.isAbsolute(storageKey) ? storageKey : findStorage(storageKey).path const exportTasks = [] @@ -24,20 +24,18 @@ function exportNote (storageKey, noteContent, targetPath, outputFormatter) { throw new Error('Storage path is not found') } - let exportedData = noteContent - - if (outputFormatter) { - exportedData = outputFormatter(exportedData, exportTasks) - } + const exportedData = outputFormatter ? outputFormatter(note, targetPath, exportTasks) : note.content const tasks = prepareTasks(exportTasks, storagePath, path.dirname(targetPath)) - return Promise.all(tasks.map((task) => copyFile(task.src, task.dst))) + return Promise + .all(tasks.map(task => copyFile(task.src, task.dst))) .then(() => { return saveToFile(exportedData, targetPath) - }).catch((err) => { + }) + .catch(error => { rollbackExport(tasks) - throw err + throw error }) } @@ -57,10 +55,12 @@ function prepareTasks (tasks, storagePath, targetPath) { function saveToFile (data, filename) { return new Promise((resolve, reject) => { - fs.writeFile(filename, data, (err) => { - if (err) return reject(err) - - resolve(filename) + fs.writeFile(filename, data, error => { + if (error) { + reject(error) + } else { + resolve(filename) + } }) }) } diff --git a/browser/main/lib/dataApi/exportStorage.js b/browser/main/lib/dataApi/exportStorage.js index ce2c4573..1bcacd51 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 formatMarkdown from './formatMarkdown' +import formatHTML from './formatHTML' /** * @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,34 +33,61 @@ 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 }) => { + let contentFormatter = null + if (fileType === 'md') { + contentFormatter = formatMarkdown({ + storagePath: storage.path, + export: config.export + }) + } else if (fileType === 'html') { + contentFormatter = 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, + 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 + }) + } + const folderNamesMapping = {} 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 { - storage, - fileType, - exportDir - } + return Promise + .all(notes.map(note => { + const targetPath = path.join(folderNamesMapping[note.folder], `${filenamify(note.title, {replacement: '_'})}.${fileType}`) + + return exportNote(storage.key, note, targetPath, contentFormatter) + })) + .then(() => ({ + storage, + fileType, + exportDir + })) }) } diff --git a/browser/main/lib/dataApi/formatHTML.js b/browser/main/lib/dataApi/formatHTML.js new file mode 100644 index 00000000..de99421f --- /dev/null +++ b/browser/main/lib/dataApi/formatHTML.js @@ -0,0 +1,295 @@ +import path from 'path' +import fileUrl from 'file-url' +import { remote } from 'electron' +import consts from 'browser/lib/consts' +import Markdown from 'browser/lib/markdown' +import attachmentManagement from './attachmentManagement' + +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` +] + +const defaultFontFamily = ['helvetica', 'arial', 'sans-serif'] +if (global.process.platform !== 'darwin') { + defaultFontFamily.unshift('Microsoft YaHei') + defaultFontFamily.unshift('meiryo') +} + +const defaultCodeBlockFontFamily = [ + 'Monaco', + 'Menlo', + 'Ubuntu Mono', + 'Consolas', + 'source-code-pro', + 'monospace' +] + +/** + * ``` + * { + * fontFamily, + * fontSize, + * lineNumber, + * codeBlockFontFamily, + * codeBlockTheme, + * scrollPastEnd, + * theme, + * allowCustomCSS, + * customCSS + * smartQuotes, + * sanitize, + * breaks, + * storagePath, + * export + * } + * ``` + */ +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 + + const markdown = new Markdown({ + typographer: smartQuotes, + sanitize, + breaks + }) + + const files = [getCodeThemeLink(codeBlockTheme), ...CSS_FILES] + + return function (note, targetPath, exportTasks) { + let body = markdown.render(note.content) + + const attachmentsAbsolutePaths = attachmentManagement.getAbsolutePathsOfAttachmentsInContent(note.content, props.storagePath) + + files.forEach(file => { + if (global.process.platform === 'win32') { + file = file.replace('file:///', '') + } else { + file = file.replace('file://', '') + } + exportTasks.push({ + src: 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) + + let styles = '' + files.forEach(file => { + styles += `` + }) + + return ` + + + + + ${styles} + + ${body} + ` + } +} + +export function getStyleParams (props) { + const { + fontSize, + lineNumber, + codeBlockTheme, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS + } = 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 + } +} + +export function getCodeThemeLink (theme) { + if (consts.THEMES.some(_theme => _theme === theme)) { + theme = theme !== 'default' ? theme : 'elegant' + } + + return theme.startsWith('solarized') + ? `${appPath}/node_modules/codemirror/theme/solarized.css` + : `${appPath}/node_modules/codemirror/theme/${theme}.css` +} + +export function buildStyle ( + fontFamily, + fontSize, + codeBlockFontFamily, + lineNumber, + scrollPastEnd, + theme, + allowCustomCSS, + customCSS +) { + 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;'} +} +@media print { + body { + padding-bottom: initial; + } +} +code { + font-family: '${codeBlockFontFamily.join("','")}'; + background-color: rgba(0,0,0,0.04); +} +.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 : ''} +` +} diff --git a/browser/main/lib/dataApi/formatMarkdown.js b/browser/main/lib/dataApi/formatMarkdown.js new file mode 100644 index 00000000..6ba00eac --- /dev/null +++ b/browser/main/lib/dataApi/formatMarkdown.js @@ -0,0 +1,94 @@ +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 (lines[0] === '---') { + let line = 0 + while (++line < lines.length && 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/moveNote.js b/browser/main/lib/dataApi/moveNote.js index 2d306cdf..c38968cb 100644 --- a/browser/main/lib/dataApi/moveNote.js +++ b/browser/main/lib/dataApi/moveNote.js @@ -1,7 +1,6 @@ const resolveStorageData = require('./resolveStorageData') const _ = require('lodash') const path = require('path') -const fs = require('fs') const CSON = require('@rokt33r/season') const keygen = require('browser/lib/keygen') const sander = require('sander') diff --git a/browser/main/modals/PreferencesModal/ExportTab.js b/browser/main/modals/PreferencesModal/ExportTab.js index b2036646..b7813cf7 100644 --- a/browser/main/modals/PreferencesModal/ExportTab.js +++ b/browser/main/modals/PreferencesModal/ExportTab.js @@ -30,21 +30,19 @@ class ExportTab extends React.Component { componentDidMount () { this.handleSettingDone = () => { this.setState({ - ExportAlert: { - type: 'success', - message: i18n.__('Successfully applied!') - } + 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!') - } + ExportAlert: { + type: 'error', + message: err.message != null ? err.message : i18n.__('An error occurred!') } - ) + }) } this.oldExport = this.state.config.export @@ -78,8 +76,9 @@ class ExportTab extends React.Component { const { config } = this.state config.export = { - action: this.refs.action.value, - variable: !_.isNil(this.refs.variable) ? this.refs.variable.value : config.export.variable + metadata: this.refs.metadata.value, + variable: !_.isNil(this.refs.variable) ? this.refs.variable.value : config.export.variable, + prefixAttachmentFolder: this.refs.prefixAttachmentFolder.checked } this.setState({ @@ -99,7 +98,6 @@ class ExportTab extends React.Component { render () { const { config, ExportAlert } = this.state - console.log(config.export) const ExportAlertElement = ExportAlert != null ?

@@ -114,12 +112,12 @@ class ExportTab extends React.Component {

- {i18n.__('Action')} + {i18n.__('Metadata')}
- this.handleExportChange(e)} + checked={config.export.prefixAttachmentFolder} + ref='prefixAttachmentFolder' + type='checkbox' + />  + {i18n.__('Prefix attachment folder')} + +
+