1
0
mirror of https://github.com/BoostIo/Boostnote synced 2025-12-14 10:16:26 +00:00

Compare commits

..

42 Commits

Author SHA1 Message Date
KZ
789926bc76 Merge pull request #3726 from BoostIO/update-readme
Update readme
2021-09-02 10:17:08 +09:00
KZ
006795b4d0 Update readme 2021-09-02 10:16:59 +09:00
Junyoung Choi
3e5d78d322 Update readme.md 2021-09-01 18:17:07 +09:00
Baptiste Augrain
58c4a78be1 avoids conflicting styles between inline codes and code blocks 2020-12-27 11:16:55 +09:00
Gonçalo Santos
2603dfc1ed Fix Analytics save bug 2020-12-12 00:15:37 +09:00
Gonçalo Santos
2df590600b AutoUpdate is auto saved 2020-12-12 00:15:37 +09:00
Gonçalo Santos
ef20a8f3e5 Remove debug statements 2020-12-12 00:15:37 +09:00
Gonçalo Santos
3e405e1abf Fix Cancel update 2020-12-12 00:15:37 +09:00
Gonçalo Santos
553832bdfa Create confirm download dialog 2020-12-12 00:15:37 +09:00
Gonçalo Santos
18d65d999a Menu item calls update-check 2020-12-12 00:15:37 +09:00
Gonçalo Santos
3b5eff582a Update not found message 2020-12-12 00:15:37 +09:00
Gonçalo Santos
85d09b3b3d Add update menu item 2020-12-12 00:15:37 +09:00
Baptiste Augrain
8958e67fcf fix unwanted deletion of attachments 2020-09-15 12:33:12 +09:00
Junyoung Choi
47b796909a v0.16.1 2020-09-04 23:47:01 +09:00
Junyoung Choi
67d76abdfa Merge branch 'master' of github.com:BoostIO/Boostnote 2020-09-04 23:45:41 +09:00
Junyoung Choi
d75d68ba72 Add email subscription form 2020-09-04 23:44:16 +09:00
Junyoung Choi
323be6b72d Merge pull request #2612 from daiyam/export-yfm
improve export
2020-07-22 18:24:27 +09:00
Junyoung Choi
031a113338 Merge branch 'master' of github.com:BoostIO/Boostnote 2020-07-20 21:07:26 +09:00
Junyoung Choi
b50c5386a6 Add BoostHub link to menu 2020-07-20 21:06:55 +09:00
Baptiste Augrain
65777b1d56 Merge branch 'master' into export-yfm 2020-07-20 14:05:21 +02:00
Junyoung Choi
fe728874ac Fix code sign script 2020-07-20 21:02:29 +09:00
Baptiste Augrain
bd9b1306b1 fix missing isStacking flag 2020-06-26 17:55:16 +02:00
Baptiste Augrain
0ca18d8ca5 re-add missing isStacking flag 2020-06-26 03:07:25 +02:00
Baptiste Augrain
87a530612f - use newest test
- remove useless binding
- regroup function
2020-06-25 22:50:08 +02:00
Baptiste Augrain
f4259bb4d0 fix exporting storage's notes into their own folders 2020-06-12 17:36:46 +02:00
Baptiste Augrain
aa8b589569 don't show export menu to snippet notes 2020-06-12 16:56:07 +02:00
Baptiste Augrain
febc98c101 fix exporting storage's notes as PDFs 2020-06-12 16:51:35 +02:00
Baptiste Augrain
b678c3bd89 export html is escaping html characters as the preview 2020-06-12 16:40:16 +02:00
Baptiste Augrain
80b8948433 - export untitled notes as 'Untitled' based on the language
- export notes with duplicate title as '<title> (<index>)'
2020-06-12 16:18:27 +02:00
Baptiste Augrain
5414fe3384 export tag 2020-06-12 15:53:23 +02:00
Baptiste Augrain
db4016385d Merge branch 'master' into export-yfm 2020-06-12 15:17:02 +02:00
Baptiste Augrain
9d43e34cfa Merge branch 'master' into export-yfm 2018-12-28 00:02:38 +01:00
Baptiste Augrain
fa157f6f76 fix lint error 2018-12-15 15:57:37 +01:00
Baptiste Augrain
d6a54b8a26 export diagrams 2018-12-15 15:53:49 +01:00
Baptiste Augrain
9813412c8e add export menu in note list's context menu 2018-12-15 14:55:13 +01:00
Baptiste Augrain
d76b7235db export styles of code blocks 2018-12-15 12:42:39 +01:00
Baptiste Augrain
418a789568 fix export note in with split editor 2018-12-15 10:22:02 +01:00
Baptiste Augrain
2d941c3ea3 Merge branch 'master' into export-yfm 2018-12-13 14:35:25 +01:00
Baptiste Augrain
e723d4cd59 add tests 2018-11-18 19:10:48 +01:00
Baptiste Augrain
9e770ef357 fix bad conditions 2018-11-15 23:52:42 +01:00
Baptiste Augrain
c796b3b30e add YAML front matter when exporting 2018-11-15 22:48:14 +01:00
Baptiste Augrain
168fe212f5 add preference tab 2018-11-15 03:15:11 +01:00
39 changed files with 2029 additions and 696 deletions

View File

@@ -63,7 +63,7 @@ export default class CodeEditor extends React.Component {
this.focusHandler = () => {
ipcRenderer.send('editor:focused', true)
}
const debouncedDeletionOfAttachments = _.debounce(
this.debouncedDeletionOfAttachments = _.debounce(
attachmentManagement.deleteAttachmentsNotPresentInNote,
30000
)
@@ -80,7 +80,7 @@ export default class CodeEditor extends React.Component {
this.props.onBlur != null && this.props.onBlur(e)
const { storageKey, noteKey } = this.props
if (this.props.deleteUnusedAttachments === true) {
debouncedDeletionOfAttachments(
this.debouncedDeletionOfAttachments(
this.editor.getValue(),
storageKey,
noteKey
@@ -810,6 +810,8 @@ export default class CodeEditor extends React.Component {
}
handleChange(editor, changeObject) {
this.debouncedDeletionOfAttachments.cancel()
spellcheck.handleChange(editor, changeObject)
// The current note contains an toc. We'll check for changes on headlines.

View File

@@ -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}
/>

View File

@@ -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 += `<link rel="stylesheet" href="../css/${path.basename(file)}">`
})
return `<html>
<head>
<base href="file://${targetDir}/">
<meta charset="UTF-8">
<meta name = "viewport" content = "width = device-width, initial-scale = 1, maximum-scale = 1">
<style id="style">${inlineStyles}</style>
${styles}
</head>
<body>${body}</body>
</html>`
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 => {

View File

@@ -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}
/>
</div>

View File

@@ -8,7 +8,7 @@ import markdownItTocAndAnchor from '@hikerpig/markdown-it-toc-and-anchor'
import _ from 'lodash'
import ConfigManager from 'browser/main/lib/ConfigManager'
import katex from 'katex'
import { lastFindInArray } from './utils'
import { escapeHtmlCharacters, lastFindInArray } from './utils'
function createGutter(str, firstLineNumber) {
if (Number.isNaN(firstLineNumber)) firstLineNumber = 1
@@ -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 `<pre class="fence" data-line="${token.map[0]}">
<span class="filename">${token.fileName}</span>
<div class="chart" data-height="${
token.parameters.height
}" data-format="${token.parameters.format || 'json'}">${
<span class="filename">${token.fileName}</span>
<div class="chart" data-height="${
token.parameters.height
}" data-format="${token.parameters.format || 'json'}">${
token.content
}</div>
</pre>`
</pre>`
},
flowchart: token => {
updatedOptions.onFence('flowchart')
return `<pre class="fence" data-line="${token.map[0]}">
<span class="filename">${token.fileName}</span>
<div class="flowchart" data-height="${token.parameters.height}">${
<span class="filename">${token.fileName}</span>
<div class="flowchart" data-height="${token.parameters.height}">${
token.content
}</div>
</pre>`
</pre>`
},
gallery: token => {
const content = token.content
@@ -298,35 +303,41 @@ class Markdown {
.join('\n')
return `<pre class="fence" data-line="${token.map[0]}">
<span class="filename">${token.fileName}</span>
<div class="gallery" data-autoplay="${
token.parameters.autoplay
}" data-height="${token.parameters.height}">${content}</div>
</pre>`
<span class="filename">${token.fileName}</span>
<div class="gallery" data-autoplay="${
token.parameters.autoplay
}" data-height="${token.parameters.height}">${content}</div>
</pre>`
},
mermaid: token => {
updatedOptions.onFence('mermaid')
return `<pre class="fence" data-line="${token.map[0]}">
<span class="filename">${token.fileName}</span>
<div class="mermaid" data-height="${token.parameters.height}">${
<span class="filename">${token.fileName}</span>
<div class="mermaid" data-height="${token.parameters.height}">${
token.content
}</div>
</pre>`
</pre>`
},
sequence: token => {
updatedOptions.onFence('sequence')
return `<pre class="fence" data-line="${token.map[0]}">
<span class="filename">${token.fileName}</span>
<div class="sequence" data-height="${token.parameters.height}">${
<span class="filename">${token.fileName}</span>
<div class="sequence" data-height="${token.parameters.height}">${
token.content
}</div>
</pre>`
</pre>`
}
},
token => {
updatedOptions.onFence('code', token.langType)
return `<pre class="code CodeMirror" data-line="${token.map[0]}">
<span class="filename">${token.fileName}</span>
${createGutter(token.content, token.firstLineNumber)}
<code class="${token.langType}">${token.content}</code>
</pre>`
<span class="filename">${token.fileName}</span>
${createGutter(token.content, token.firstLineNumber)}
<code class="${token.langType}">${token.content}</code>
</pre>`
}
)
@@ -468,6 +479,16 @@ class Markdown {
return true
})
this.md.renderer.rules.code_inline = function(tokens, idx) {
const token = tokens[idx]
return (
'<code class="inline">' +
escapeHtmlCharacters(token.content) +
'</code>'
)
}
if (config.preview.smartArrows) {
this.md.use(smartArrows)
}

View File

@@ -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}
/>
)

View File

@@ -18,6 +18,7 @@ import { getLocales } from 'browser/lib/Languages'
import applyShortcuts from 'browser/main/lib/shortcutManager'
import { chooseTheme, applyTheme } from 'browser/main/lib/ThemeManager'
import { push } from 'connected-react-router'
import { ipcRenderer } from 'electron'
const path = require('path')
const electron = require('electron')
@@ -184,6 +185,7 @@ class Main extends React.Component {
this.toggleMenuBarVisible.bind(this)
)
eventEmitter.on('dispatch:push', this.changeRoutePush.bind(this))
eventEmitter.on('update', () => ipcRenderer.send('update-check', 'manual'))
}
componentWillUnmount() {

View File

@@ -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)
}
)
}
}
}

View File

@@ -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
})
}
})

View File

@@ -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}
/>
)
})

View File

@@ -12,6 +12,7 @@ import DevTools from './DevTools'
require('./lib/ipcClient')
require('../lib/customMeta')
import i18n from 'browser/lib/i18n'
import ConfigManager from './lib/ConfigManager'
const electron = require('electron')
@@ -107,6 +108,22 @@ function updateApp() {
}
}
function downloadUpdate() {
const index = dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning',
message: i18n.__('Update Boostnote'),
detail: i18n.__('New Boostnote is ready to be downloaded.'),
buttons: [i18n.__('Download now'), i18n.__('Ignore updates')]
})
if (index === 0) {
ipcRenderer.send('update-download-confirm')
} else if (index === 1) {
ipcRenderer.send('update-cancel')
ConfigManager.set({ autoUpdateEnabled: false })
}
}
ReactDOM.render(
<Provider store={store}>
<ConnectedRouter history={history}>
@@ -147,8 +164,12 @@ ReactDOM.render(
})
ipcRenderer.on('update-found', function() {
notify('Update found!', {
body: 'Preparing to update...'
downloadUpdate()
})
ipcRenderer.on('update-not-found', function(_, msg) {
notify('Update not found!', {
body: msg
})
})

View File

@@ -18,9 +18,9 @@ const DEFAULT_MARKDOWN_LINT_CONFIG = `{
const DEFAULT_CSS_CONFIG = `
/* Drop Your Custom CSS Code Here */
[data-theme="default"] p code,
[data-theme="default"] li code,
[data-theme="default"] td code
[data-theme="default"] p code.inline,
[data-theme="default"] li code.inline,
[data-theme="default"] td code.inline
{
padding: 2px;
border-width: 1px;
@@ -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

View File

@@ -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,

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)
}
})
}

View File

@@ -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

View File

@@ -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
}
}))
})
}

View File

@@ -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

View File

@@ -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 => `<link rel="stylesheet" href="css/${path.basename(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 += `<script src="js/lodash.min.js"></script>`
}
let raphael = false
function addRaphael() {
if (raphael) {
return
}
raphael = true
exportTasks.push({
src: unprefix(`${appPath}/node_modules/raphael/raphael.min.js`),
dst: 'js'
})
scripts += `<script src="js/raphael.min.js"></script>`
}
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 += `<script src="js/js-yaml.min.js"></script>`
}
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 += `<script src="js/Chart.min.js"></script>`
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 += `
<script src="js/codemirror/codemirror.js"></script>
<script src="js/codemirror/mode/meta.js"></script>
<script src="js/codemirror/addon/mode/loadmode.js"></script>
<script src="js/codemirror/addon/runmode/runmode.js"></script>
`
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 += `<script src="js/flowchart.min.js"></script>`
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 += `<script src="js/mermaid.min.js"></script>`
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 += `<script src="js/sequence-diagram-min.js"></script>`
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 `
<html>
<head>
<meta charset="UTF-8">
<meta name = "viewport" content = "width = device-width, initial-scale = 1, maximum-scale = 1">
<style id="style">${inlineStyles}</style>
${styles}
${scripts}
<script>${inlineScripts}</script>
</head>
<body data-theme="${theme}">
${body}
</body>
</html>
`
}
}
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.inline,
li code.inline,
td code.inline
{
padding: 2px;
border-width: 1px;
border-style: solid;
border-radius: 5px;
}
[data-theme="default"] p code.inline,
[data-theme="default"] li code.inline,
[data-theme="default"] td code.inline
{
background-color: #F4F4F4;
border-color: #d9d9d9;
color: inherit;
}
[data-theme="white"] p code.inline,
[data-theme="white"] li code.inline,
[data-theme="white"] td code.inline
{
background-color: #F4F4F4;
border-color: #d9d9d9;
color: inherit;
}
[data-theme="dark"] p code.inline,
[data-theme="dark"] li code.inline,
[data-theme="dark"] td code.inline
{
background-color: #444444;
border-color: #555;
color: #FFFFFF;
}
[data-theme="dracula"] p code.inline,
[data-theme="dracula"] li code.inline,
[data-theme="dracula"] td code.inline
{
background-color: #444444;
border-color: #555;
color: #FFFFFF;
}
[data-theme="monokai"] p code.inline,
[data-theme="monokai"] li code.inline,
[data-theme="monokai"] td code.inline
{
background-color: #444444;
border-color: #555;
color: #FFFFFF;
}
[data-theme="nord"] p code.inline,
[data-theme="nord"] li code.inline,
[data-theme="nord"] td code.inline
{
background-color: #444444;
border-color: #555;
color: #FFFFFF;
}
[data-theme="solarized-dark"] p code.inline,
[data-theme="solarized-dark"] li code.inline,
[data-theme="solarized-dark"] td code.inline
{
background-color: #444444;
border-color: #555;
color: #FFFFFF;
}
[data-theme="vulcan"] p code.inline,
[data-theme="vulcan"] li code.inline,
[data-theme="vulcan"] td code.inline
{
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
}

View File

@@ -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}`
}
}

View File

@@ -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()
})
})
})
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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'),

View File

@@ -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 ? (
<p className={`alert ${ExportAlert.type}`}>{ExportAlert.message}</p>
) : null
return (
<div styleName='root'>
<div styleName='group'>
<div styleName='group-header'>{i18n.__('Export')}</div>
<div styleName='group-section'>
<div styleName='group-section-label'>{i18n.__('Metadata')}</div>
<div styleName='group-section-control'>
<select
value={config.export.metadata}
onChange={e => this.handleExportChange(e)}
ref='metadata'
>
<option value='DONT_EXPORT'>{i18n.__(`Don't export`)}</option>
<option value='MERGE_HEADER'>
{i18n.__('Merge with the header')}
</option>
<option value='MERGE_VARIABLE'>
{i18n.__('Merge with a variable')}
</option>
</select>
</div>
</div>
{config.export.metadata === 'MERGE_VARIABLE' && (
<div styleName='group-section'>
<div styleName='group-section-label'>
{i18n.__('Variable Name')}
</div>
<div styleName='group-section-control'>
<input
styleName='group-section-control-input'
onChange={e => this.handleExportChange(e)}
ref='variable'
value={config.export.variable}
type='text'
/>
</div>
</div>
)}
<div styleName='group-checkBoxSection'>
<label>
<input
onChange={e => this.handleExportChange(e)}
checked={config.export.prefixAttachmentFolder}
ref='prefixAttachmentFolder'
type='checkbox'
/>
&nbsp;
{i18n.__('Prefix attachment folder')}
</label>
</div>
<div styleName='group-control'>
<button
styleName='group-control-rightButton'
onClick={e => this.handleSaveButtonClick(e)}
>
{i18n.__('Save')}
</button>
{ExportAlertElement}
</div>
</div>
</div>
)
}
}
ExportTab.propTypes = {
dispatch: PropTypes.func,
haveToSave: PropTypes.func
}
export default CSSModules(ExportTab, styles)

View File

@@ -16,25 +16,78 @@ class InfoTab extends React.Component {
super(props)
this.state = {
config: this.props.config
config: this.props.config,
subscriptionFormStatus: 'idle',
subscriptionFormErrorMessage: null,
subscriptionFormEmail: ''
}
}
componentDidMount() {
const { autoUpdateEnabled, amaEnabled } = ConfigManager.get()
this.setState({ config: { autoUpdateEnabled, amaEnabled } })
}
handleLinkClick(e) {
shell.openExternal(e.currentTarget.href)
e.preventDefault()
}
handleConfigChange(e) {
const newConfig = { amaEnabled: this.refs.amaEnabled.checked }
const newConfig = {
amaEnabled: this.refs.amaEnabled.checked,
autoUpdateEnabled: this.refs.autoUpdateEnabled.checked
}
this.setState({ config: newConfig })
return newConfig
}
handleSubscriptionFormSubmit(e) {
e.preventDefault()
this.setState({
subscriptionFormStatus: 'sending',
subscriptionFormErrorMessage: null
})
fetch(
'https://boostmails.boostio.co/api/public/lists/5f434dccd05f3160b41c0d49/subscriptions',
{
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
method: 'POST',
body: JSON.stringify({ email: this.state.subscriptionFormEmail })
}
)
.then(response => {
if (response.status >= 400) {
return response.text().then(text => {
throw new Error(text)
})
}
this.setState({
subscriptionFormStatus: 'done'
})
})
.catch(error => {
this.setState({
subscriptionFormStatus: 'idle',
subscriptionFormErrorMessage: error.message
})
})
}
handleSubscriptionFormEmailChange(e) {
this.setState({
subscriptionFormEmail: e.target.value
})
}
handleSaveButtonClick(e) {
const newConfig = {
amaEnabled: this.state.config.amaEnabled
}
const newConfig = this.state.config
if (!newConfig.amaEnabled) {
AwsMobileAnalyticsConfig.recordDynamicCustomEvent('DISABLE_AMA')
@@ -61,20 +114,17 @@ class InfoTab extends React.Component {
})
}
toggleAutoUpdate() {
const newConfig = {
autoUpdateEnabled: !this.state.config.autoUpdateEnabled
}
this.setState({ config: newConfig })
ConfigManager.set(newConfig)
}
infoMessage() {
const { amaMessage } = this.state
return amaMessage ? <p styleName='policy-confirm'>{amaMessage}</p> : null
}
handleAutoUpdateChange() {
const { autoUpdateEnabled } = this.handleConfigChange()
ConfigManager.set({ autoUpdateEnabled })
}
render() {
return (
<div styleName='root'>
@@ -134,6 +184,40 @@ class InfoTab extends React.Component {
<hr />
<div styleName='group-header--sub'>Subscribe Update Notes</div>
{this.state.subscriptionFormStatus === 'done' ? (
<div>
<blockquote color={{ color: 'green' }}>
Thanks for the subscription!
</blockquote>
</div>
) : (
<div>
{this.state.subscriptionFormErrorMessage != null && (
<blockquote style={{ color: 'red' }}>
{this.state.subscriptionFormErrorMessage}
</blockquote>
)}
<form onSubmit={e => this.handleSubscriptionFormSubmit(e)}>
<input
styleName='subscription-email-input'
placeholder='E-mail'
type='email'
onChange={e => this.handleSubscriptionFormEmailChange(e)}
disabled={this.state.subscriptionFormStatus === 'sending'}
/>
<button
styleName='subscription-submit-button'
type='submit'
disabled={this.state.subscriptionFormStatus === 'sending'}
>
Subscribe
</button>
</form>
</div>
)}
<hr />
<div styleName='group-header--sub'>{i18n.__('About')}</div>
<div styleName='top'>
@@ -181,7 +265,8 @@ class InfoTab extends React.Component {
<label>
<input
type='checkbox'
onChange={this.toggleAutoUpdate.bind(this)}
ref='autoUpdateEnabled'
onChange={() => this.handleAutoUpdateChange()}
checked={this.state.config.autoUpdateEnabled}
/>
{i18n.__('Enable Auto Update')}

View File

@@ -33,6 +33,35 @@
.separate-line
margin 40px 0
.subscription-email-input
height 35px
vertical-align middle
width 200px
font-size $tab--button-font-size
border solid 1px $border-color
border-radius 2px
padding 0 5px
margin-right 5px
outline none
&:disabled
background-color $ui-input--disabled-backgroundColor
.subscription-submit-button
margin-top 10px
height 35px
border-radius 2px
border none
background-color alpha(#1EC38B, 90%)
padding-left 20px
padding-right 20px
text-decoration none
color white
font-weight 600
font-size 16px
&:hover
background-color #1EC38B
transition 0.2s
.policy-submit
margin-top 10px
height 35px

View File

@@ -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 (
<ExportTab
dispatch={dispatch}
config={config}
data={data}
haveToSave={alert => this.setState({ ExportAlert: alert })}
/>
)
case 'SNIPPET':
return <SnippetTab dispatch={dispatch} config={config} data={data} />
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') }
]

View File

@@ -187,7 +187,7 @@ module.exports = function(grunt) {
}
ChildProcess.exec(
`codesign --verbose --deep --force --sign \"${OSX_COMMON_NAME}\" dist/Boostnote-darwin-x64/Boostnote.app`,
`codesign --verbose --deep --force --timestamp=none --sign \"${OSX_COMMON_NAME}\" dist/Boostnote-darwin-x64/Boostnote.app`,
function(err, stdout, stderr) {
grunt.log.writeln(stdout)
if (err) {

View File

@@ -26,6 +26,7 @@ if (!singleInstance) {
}
var isUpdateReady = false
let updateFound = false
var ghReleasesOpts = {
repo: 'BoostIO/boost-releases',
@@ -36,25 +37,33 @@ const updater = new GhReleases(ghReleasesOpts)
// Check for updates
// `status` returns true if there is a new update available
function checkUpdate() {
function checkUpdate(manualTriggered = false) {
if (!isPackaged) {
// Prevents app from attempting to update when in dev mode.
console.log('Updates are disabled in Development mode, see main-app.js')
return true
}
if (!electronConfig.get('autoUpdateEnabled', true)) return
if (process.platform === 'linux' || isUpdateReady) {
// End if auto updates disabled and it is an automatic check
if (!electronConfig.get('autoUpdateEnabled', true) && !manualTriggered) return
if (process.platform === 'linux' || isUpdateReady || updateFound) {
return true
}
updater.check((err, status) => {
if (err) {
var isLatest = err.message === 'There is no newer version.'
if (!isLatest) console.error('Updater error! %s', err.message)
mainWindow.webContents.send(
'update-not-found',
isLatest ? 'There is no newer version.' : 'Updater error'
)
return
}
if (status) {
mainWindow.webContents.send('update-found', 'Update available!')
updater.download()
updateFound = true
}
})
}
@@ -63,6 +72,7 @@ updater.on('update-downloaded', info => {
if (mainWindow != null) {
mainWindow.webContents.send('update-ready', 'Update available!')
isUpdateReady = true
updateFound = false
}
})
@@ -77,6 +87,14 @@ ipc.on('update-app-confirm', function(event, msg) {
}
})
ipc.on('update-cancel', () => {
updateFound = false
})
ipc.on('update-download-confirm', () => {
updater.download()
})
app.on('window-all-closed', function() {
app.quit()
})
@@ -113,7 +131,7 @@ app.on('ready', function() {
if (isUpdateReady) {
mainWindow.webContents.send('update-ready', 'Update available!')
} else {
checkUpdate()
checkUpdate(msg === 'manual')
}
})
}, 10 * 1000)

View File

@@ -178,6 +178,18 @@ const file = {
mainWindow.webContents.send('list:isMarkdownNote', 'print')
mainWindow.webContents.send('print')
}
},
{
type: 'separator'
},
{
label: 'Update',
click() {
mainWindow.webContents.send('update')
}
},
{
type: 'separator'
}
]
}
@@ -472,9 +484,21 @@ const help = {
]
}
const team = {
label: 'For Team',
submenu: [
{
label: 'BoostHub',
click: async () => {
shell.openExternal('https://boosthub.io/')
}
}
]
}
module.exports =
process.platform === 'darwin'
? [boost, file, edit, view, window, help]
? [boost, file, edit, view, window, team, help]
: process.platform === 'win32'
? [boost, file, view, help]
: [file, view, help]
? [boost, file, view, team, help]
: [file, view, team, help]

View File

@@ -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",

View File

@@ -1,7 +1,7 @@
{
"name": "boost",
"productName": "Boostnote",
"version": "0.16.0",
"version": "0.16.1",
"main": "index.js",
"description": "Boostnote",
"license": "GPL-3.0",

View File

@@ -1,12 +1,26 @@
> [We've launched desktop and mobile app of the new Boost Note now.](https://github.com/BoostIO/BoostNote.next)
> ### [Boost Note for Teams](https://boosthub.io/)
> # New Boost Note app is available!
>
> We've developed a collaborative workspace app called "Boost Hub" for developer teams.
> We've launched a new Boost Note app which supports real-time collaborative writing.
>
> It's customizable and easy to optimize for your team like rego blocks and even lets you edit documents together in real-time!
> And it is open sourced too! Please check it out! https://github.com/BoostIO/BoostNote-App
>
> ## 📦 Download App
>
> ### 🖥 Desktop
>
> - [🌎 Web App (boostnote.io)](https://boostnote.io)
> - [🍎 macOS (.dmg)](https://github.com/BoostIO/BoostNote-App/releases/latest/download/boost-note-mac.dmg)
> - [:framed_picture: Windows (.exe NSIS)](https://github.com/BoostIO/BoostNote-App/releases/latest/download/boost-note-win.exe)
> - [🐧 Linux (.deb)](https://github.com/BoostIO/BoostNote-App/releases/latest/download/boost-note-linux.deb)
> - [🐧 Linux (.rpm)](https://github.com/BoostIO/BoostNote-App/releases/latest/download/boost-note-linux.rpm)
>
> ### 📱 Mobile
>
> - [🌎 Mobile Web App (m.boostnote.io)](https://m.boostnote.io)
> - [🍏 iOS (Apple App Store)](https://apps.apple.com/gb/app/boost-note-mobile/id1576176505)
> - [🤖 Android (Google Play Store)](https://play.google.com/store/apps/details?id=com.boostio.boostnote2021)
![Boostnote app screenshot](./resources/repository/top.png)
<h1 align="center">BoostNote-Legacy</h1>
<h4 align="center">Note-taking app for programmers. </h4>
<h5 align="center">Apps available for Mac, Windows and Linux.</h5>
@@ -21,42 +35,6 @@
[Find the latest release of Boostnote here!](https://github.com/BoostIO/boost-releases/releases/)
## Roadmap
[Boost Note Roadmap 2020](https://medium.com/boostnote/boost-note-roadmap-2020-9f06a642f5f1)
## Authors & Maintainers
- [Rokt33r](https://github.com/rokt33r)
- [KZ](https://github.com/kazup01)
- [ZeroX-DG](https://github.com/ZeroX-DG)
## Contributors
Thank you to all the people who have contributed to Boostnote!
<a href="https://github.com/BoostIO/Boostnote/graphs/contributors"><img src="https://opencollective.com/boostnoteio/contributors.svg?width=890" /></a>
## Supporting Boostnote
Boostnote is an open source project. It's an independent project with its ongoing development made possible thanks to the support by our amazing backers.
Issues on Boostnote can be funded by anyone and the money will be distributed to contributors and maintainers. If you use Boostnote please consider becoming a backer:
[![Let's fund issues in this repository](https://issuehunt.io/static/embed/issuehunt-button-v1.svg)](https://issuehunt.io/repos/53266139)
## Community
- [Facebook Group](https://www.facebook.com/groups/boostnote/)
- [Twitter](https://twitter.com/boostnoteapp)
- [Slack Group](https://join.slack.com/t/boostnote-group/shared_invite/zt-cun7pas3-WwkaezxHBB1lCbUHrwQLXw)
- [Blog](https://medium.com/boostnote)
- [Reddit](https://www.reddit.com/r/Boostnote/)
### Boostnote mobile
A community project developing a mobile cross-platform version of boostnote for iOS and Android can be found here: [NoteApp](https://github.com/T0M0F/NoteApp)
#### More Information
- Website: https://boostnote.io

View File

@@ -702,14 +702,15 @@ it('should remove the all ":storage" and noteKey references', function() {
' </p>\n' +
' </body>\n' +
'</html>'
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 =
'<html>\n' +
' <head>\n' +
' //header\n' +
' </head>\n' +
' <body data-theme="default">\n' +
' <h2 data-line="0" id="Headline">Headline</h2>\n' +
' <p data-line="2">\n' +
' <img src=":storage' +
mdurl.encode(path.sep) +
noteKey +
mdurl.encode(path.sep) +
'0.6r4zdgc22xp.png" alt="dummyImage.png" >\n' +
' </p>\n' +
' <p data-line="4">\n' +
' <a href=":storage' +
mdurl.encode(path.sep) +
noteKey +
mdurl.encode(path.sep) +
'0.q2i4iw0fyx.pdf">dummyPDF.pdf</a>\n' +
' </p>\n' +
' <p data-line="6">\n' +
' <img src=":storage' +
mdurl.encode(path.sep) +
noteKey +
mdurl.encode(path.sep) +
'd6c5ee92.jpg" alt="dummyImage2.jpg">\n' +
' </p>\n' +
' </body>\n' +
'</html>'
const expectedOutput =
'<html>\n' +
' <head>\n' +
' //header\n' +
' </head>\n' +
' <body data-theme="default">\n' +
' <h2 data-line="0" id="Headline">Headline</h2>\n' +
' <p data-line="2">\n' +
' <img src="' +
storageFolder +
path.sep +
'0.6r4zdgc22xp.png" alt="dummyImage.png" >\n' +
' </p>\n' +
' <p data-line="4">\n' +
' <a href="' +
storageFolder +
path.sep +
'0.q2i4iw0fyx.pdf">dummyPDF.pdf</a>\n' +
' </p>\n' +
' <p data-line="6">\n' +
' <img src="' +
storageFolder +
path.sep +
'd6c5ee92.jpg" alt="dummyImage2.jpg">\n' +
' </p>\n' +
' </body>\n' +
'</html>'
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)
})

View File

@@ -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')

View File

@@ -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,

View File

@@ -98,11 +98,11 @@ exports[`Markdown.render() should renders checkboxes 1`] = `
exports[`Markdown.render() should renders codeblock correctly 1`] = `
"<pre class=\\"code CodeMirror\\" data-line=\\"1\\">
<span class=\\"filename\\">filename.js</span>
<span class=\\"lineNumber CodeMirror-gutters\\"><span class=\\"CodeMirror-linenumber\\">2</span></span>
<code class=\\"js\\">var project = 'boostnote';
<span class=\\"filename\\">filename.js</span>
<span class=\\"lineNumber CodeMirror-gutters\\"><span class=\\"CodeMirror-linenumber\\">2</span></span>
<code class=\\"js\\">var project = 'boostnote';
</code>
</pre>"
</pre>"
`;
exports[`Markdown.render() should renders definition lists correctly 1`] = `