diff --git a/.gitignore b/.gitignore index 9f75dd1b9..ace5316cb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,5 @@ node_modules/* /compiled /secret *.log -.vscode -.idea \ No newline at end of file +.idea +.vscode \ No newline at end of file diff --git a/browser/main/SideNav/StorageItem.js b/browser/main/SideNav/StorageItem.js index 4379a76c3..2bcbe0506 100644 --- a/browser/main/SideNav/StorageItem.js +++ b/browser/main/SideNav/StorageItem.js @@ -10,6 +10,7 @@ import dataApi from 'browser/main/lib/dataApi' import StorageItemChild from 'browser/components/StorageItem' import eventEmitter from 'browser/main/lib/eventEmitter' import _ from 'lodash' +import * as path from 'path' const { remote } = require('electron') const { Menu, MenuItem, dialog } = remote @@ -24,18 +25,20 @@ class StorageItem extends React.Component { } handleHeaderContextMenu (e) { - const menu = new Menu() - menu.append(new MenuItem({ - label: 'Add Folder', - click: (e) => this.handleAddFolderButtonClick(e) - })) - menu.append(new MenuItem({ - type: 'separator' - })) - menu.append(new MenuItem({ - label: 'Unlink Storage', - click: (e) => this.handleUnlinkStorageClick(e) - })) + const menu = Menu.buildFromTemplate([ + { + label: 'Add Folder', + click: (e) => this.handleAddFolderButtonClick(e) + }, + { + type: 'separator' + }, + { + label: 'Unlink Storage', + click: (e) => this.handleUnlinkStorageClick(e) + } + ]) + menu.popup() } @@ -89,18 +92,36 @@ class StorageItem extends React.Component { } handleFolderButtonContextMenu (e, folder) { - const menu = new Menu() - menu.append(new MenuItem({ - label: 'Rename Folder', - click: (e) => this.handleRenameFolderClick(e, folder) - })) - menu.append(new MenuItem({ - type: 'separator' - })) - menu.append(new MenuItem({ - label: 'Delete Folder', - click: (e) => this.handleFolderDeleteClick(e, folder) - })) + const menu = Menu.buildFromTemplate([ + { + label: 'Rename Folder', + click: (e) => this.handleRenameFolderClick(e, folder) + }, + { + type: 'separator' + }, + { + label: 'Export Folder', + submenu: [ + { + label: 'Export as txt', + click: (e) => this.handleExportFolderClick(e, folder, 'txt') + }, + { + label: 'Export as md', + click: (e) => this.handleExportFolderClick(e, folder, 'md') + } + ] + }, + { + type: 'separator' + }, + { + label: 'Delete Folder', + click: (e) => this.handleFolderDeleteClick(e, folder) + } + ]) + menu.popup() } @@ -112,6 +133,31 @@ class StorageItem extends React.Component { }) } + handleExportFolderClick (e, folder, fileType) { + const options = { + properties: ['openDirectory', 'createDirectory'], + buttonLabel: 'Select directory', + title: 'Select a folder to export the files to', + multiSelections: false + } + dialog.showOpenDialog(remote.getCurrentWindow(), options, + (paths) => { + if (paths && paths.length === 1) { + const { storage, dispatch } = this.props + dataApi + .exportFolder(storage.key, folder.key, fileType, paths[0]) + .then((data) => { + dispatch({ + type: 'EXPORT_FOLDER', + storage: data.storage, + folderKey: data.folderKey, + fileType: data.fileType + }) + }) + } + }) + } + handleFolderDeleteClick (e, folder) { const index = dialog.showMessageBox(remote.getCurrentWindow(), { type: 'warning', diff --git a/browser/main/lib/dataApi/exportFolder.js b/browser/main/lib/dataApi/exportFolder.js new file mode 100644 index 000000000..75dba959c --- /dev/null +++ b/browser/main/lib/dataApi/exportFolder.js @@ -0,0 +1,63 @@ +import { findStorage } from 'browser/lib/findStorage' +import resolveStorageData from './resolveStorageData' +import resolveStorageNotes from './resolveStorageNotes' +import * as path from 'path' +import * as fs from 'fs' + +/** + * @param {String} storageKey + * @param {String} folderKey + * @param {String} fileType + * @param {String} exportDir + * + * @return {Object} + * ``` + * { + * storage: Object, + * folderKey: String, + * fileType: String, + * exportDir: String + * } + * ``` + */ + +function exportFolder (storageKey, folderKey, fileType, exportDir) { + let targetStorage + try { + targetStorage = findStorage(storageKey) + } catch (e) { + return Promise.reject(e) + } + + 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, `${snippet.title}.${fileType}`) + fs.writeFileSync(notePath, snippet.content, (err) => { + if (err) throw err + }) + }) + + return { + storage, + folderKey, + fileType, + exportDir + } + }) +} + +module.exports = exportFolder diff --git a/browser/main/lib/dataApi/index.js b/browser/main/lib/dataApi/index.js index 768dfe32f..311ca2f39 100644 --- a/browser/main/lib/dataApi/index.js +++ b/browser/main/lib/dataApi/index.js @@ -7,6 +7,7 @@ const dataApi = { updateFolder: require('./updateFolder'), deleteFolder: require('./deleteFolder'), reorderFolder: require('./reorderFolder'), + exportFolder: require('./exportFolder'), createNote: require('./createNote'), updateNote: require('./updateNote'), deleteNote: require('./deleteNote'), diff --git a/browser/main/store.js b/browser/main/store.js index 647d0ac9b..f63a67c72 100644 --- a/browser/main/store.js +++ b/browser/main/store.js @@ -349,6 +349,13 @@ function data (state = defaultDataMap(), action) { state.storageMap = new Map(state.storageMap) state.storageMap.set(action.storage.key, action.storage) return state + case 'EXPORT_FOLDER': + { + state = Object.assign({}, state) + state.storageMap = new Map(state.storageMap) + state.storageMap.set(action.storage.key, action.storage) + } + return state case 'DELETE_FOLDER': { state = Object.assign({}, state) diff --git a/tests/dataApi/exportFolder-test.js b/tests/dataApi/exportFolder-test.js new file mode 100644 index 000000000..ee6fb898a --- /dev/null +++ b/tests/dataApi/exportFolder-test.js @@ -0,0 +1,62 @@ +const test = require('ava') +const exportFolder = require('browser/main/lib/dataApi/exportFolder') +const createNote = require('browser/main/lib/dataApi/createNote') + +global.document = require('jsdom').jsdom('') +global.window = document.defaultView +global.navigator = window.navigator + +const Storage = require('dom-storage') +const localStorage = window.localStorage = global.localStorage = new Storage(null, { strict: true }) +const path = require('path') +const TestDummy = require('../fixtures/TestDummy') +const os = require('os') +const faker = require('faker') +const fs = require('fs') + +const storagePath = path.join(os.tmpdir(), 'test/export-note') + +test.beforeEach((t) => { + t.context.storage = TestDummy.dummyStorage(storagePath) + localStorage.setItem('storages', JSON.stringify([t.context.storage.cache])) +}) + +test.serial('Export a folder', (t) => { + const storageKey = t.context.storage.cache.key + const folderKey = t.context.storage.json.folders[0].key + + const input1 = { + type: 'MARKDOWN_NOTE', + description: '*Some* markdown text', + tags: faker.lorem.words().split(' '), + folder: folderKey + } + input1.title = 'input1' + + const input2 = { + type: 'SNIPPET_NOTE', + description: 'Some normal text', + snippets: [{ + name: faker.system.fileName(), + mode: 'text', + content: faker.lorem.lines() + }], + tags: faker.lorem.words().split(' '), + folder: folderKey + } + input2.title = 'input2' + + return createNote(storageKey, input1) + .then(function () { + return createNote(storageKey, input2) + }) + .then(function () { + return exportFolder(storageKey, folderKey, 'md', storagePath) + }) + .then(function assert () { + let filePath = path.join(storagePath, 'input1.md') + t.true(fs.existsSync(filePath)) + filePath = path.join(storagePath, 'input2.md') + t.false(fs.existsSync(filePath)) + }) +})