diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index e297e7d41..92f71c88e 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -24,13 +24,20 @@ const appDefaultState = Object.assign({}, defaultState, { route: { type: 'NAV_GO', routeName: 'Main', - params: {}, + props: {}, }, navHistory: [], + fileToImport: null, + windowCommand: null, }); class Application extends BaseApplication { + constructor() { + super(); + this.lastMenuScreen_ = null; + } + hasGui() { return true; } @@ -76,6 +83,12 @@ class Application extends BaseApplication { newState.windowContentSize = action.size; break; + case 'WINDOW_COMMAND': + + newState = Object.assign({}, state); + newState.windowCommand = { name: action.name }; + break; + } } catch (error) { error.message = 'In reducer: ' + error.message + ' Action: ' + JSON.stringify(action); @@ -90,16 +103,75 @@ class Application extends BaseApplication { if (!await reg.syncStarted()) reg.scheduleSync(); } - return super.generalMiddleware(store, next, action); + const result = await super.generalMiddleware(store, next, action); + const newState = store.getState(); + + if (action.type === 'NAV_GO' || action.type === 'NAV_BACK') { + app().updateMenu(newState.route.routeName); + } + + return result; + } - setupMenu() { + updateMenu(screen) { + if (this.lastMenuScreen_ === screen) return; + const template = [ { label: 'File', submenu: [{ + label: _('New note'), + accelerator: 'CommandOrControl+N', + screens: ['Main'], + click: () => { + this.dispatch({ + type: 'WINDOW_COMMAND', + name: 'newNote', + }); + } + }, { + label: _('New to-do'), + accelerator: 'CommandOrControl+T', + screens: ['Main'], + click: () => { + this.dispatch({ + type: 'WINDOW_COMMAND', + name: 'newTodo', + }); + } + }, { + label: _('New notebook'), + screens: ['Main'], + click: () => { + this.dispatch({ + type: 'WINDOW_COMMAND', + name: 'newNotebook', + }); + } + }, { + type: 'separator', + }, { label: _('Import Evernote notes'), - click () { } + click: () => { + const filePaths = bridge().showOpenDialog({ + properties: ['openFile', 'createDirectory'], + filters: [ + { name: _('Evernote Export Files'), extensions: ['enex'] }, + ] + }); + if (!filePaths || !filePaths.length) return; + + this.dispatch({ + type: 'NAV_GO', + routeName: 'Import', + props: { + filePath: filePaths[0], + }, + }); + } + }, { + type: 'separator', }, { label: _('Quit'), accelerator: 'CommandOrControl+Q', @@ -113,19 +185,34 @@ class Application extends BaseApplication { click () { bridge().openExternal('http://joplin.cozic.net') } }, { label: _('About Joplin'), - click () { } + click () { } }] }, - ] + ]; - const menu = Menu.buildFromTemplate(template) - Menu.setApplicationMenu(menu) + function removeUnwantedItems(template, screen) { + let output = []; + for (let i = 0; i < template.length; i++) { + const t = Object.assign({}, template[i]); + if (t.screens && t.screens.indexOf(screen) < 0) continue; + if (t.submenu) t.submenu = removeUnwantedItems(t.submenu, screen); + output.push(t); + } + return output; + } + + let screenTemplate = removeUnwantedItems(template, screen); + + const menu = Menu.buildFromTemplate(screenTemplate); + Menu.setApplicationMenu(menu); + + this.lastMenuScreen_ = screen; } async start(argv) { argv = await super.start(argv); - this.setupMenu(); + this.updateMenu('Main'); this.initRedux(); diff --git a/ElectronClient/app/gui/Header.jsx b/ElectronClient/app/gui/Header.jsx index 3568cdfc0..3eaa59f4e 100644 --- a/ElectronClient/app/gui/Header.jsx +++ b/ElectronClient/app/gui/Header.jsx @@ -35,9 +35,6 @@ class HeaderComponent extends React.Component { style.boxSizing = 'border-box'; const buttons = []; - if (showBackButton) { - buttons.push(this.makeButton('back', {}, { title: _('Back'), onClick: () => this.back_click() })); - } const buttonStyle = { height: theme.headerHeight, @@ -53,6 +50,10 @@ class HeaderComponent extends React.Component { cursor: 'default', }; + if (showBackButton) { + buttons.push(this.makeButton('back', buttonStyle, { title: _('Back'), onClick: () => this.back_click(), iconName: 'fa-chevron-left ' })); + } + if (this.props.buttons) { for (let i = 0; i < this.props.buttons.length; i++) { const o = this.props.buttons[i]; diff --git a/ElectronClient/app/gui/ImportScreen.jsx b/ElectronClient/app/gui/ImportScreen.jsx new file mode 100644 index 000000000..d9f296dff --- /dev/null +++ b/ElectronClient/app/gui/ImportScreen.jsx @@ -0,0 +1,136 @@ +const React = require('react'); +const { connect } = require('react-redux'); +const { reg } = require('lib/registry.js'); +const { Folder } = require('lib/models/folder.js'); +const { bridge } = require('electron').remote.require('./bridge'); +const { Header } = require('./Header.min.js'); +const { themeStyle } = require('../theme.js'); +const { _ } = require('lib/locale.js'); +const { filename, basename } = require('lib/path-utils.js'); +const { importEnex } = require('lib/import-enex'); + +class ImportScreenComponent extends React.Component { + + componentWillMount() { + this.setState({ + doImport: true, + filePath: this.props.filePath, + messages: [], + }); + } + + componentWillReceiveProps(newProps) { + if (newProps.filePath) { + this.setState({ + doImport: true, + filePath: newProps.filePath, + messages: [], + }); + + this.doImport(); + } + } + + componentDidMount() { + if (this.state.filePath && this.state.doImport) { + this.doImport(); + } + } + + addMessage(key, text) { + const messages = this.state.messages.slice(); + let found = false; + + for (let i = 0; i < messages.length; i++) { + if (messages[i].key === key) { + messages[i].text = text; + found = true; + break; + } + } + + if (!found) messages.push({ key: key, text: text }); + + this.setState({ messages: messages }); + } + + async doImport() { + const filePath = this.props.filePath; + const folderTitle = await Folder.findUniqueFolderTitle(filename(filePath)); + const messages = this.state.messages.slice(); + + this.addMessage('start', _('New notebook "%s" will be created and file "%s" will be imported into it', folderTitle, basename(filePath))); + + let lastProgress = ''; + let progressCount = 0; + + const options = { + onProgress: (progressState) => { + let line = []; + line.push(_('Found: %d.', progressState.loaded)); + line.push(_('Created: %d.', progressState.created)); + if (progressState.updated) line.push(_('Updated: %d.', progressState.updated)); + if (progressState.skipped) line.push(_('Skipped: %d.', progressState.skipped)); + if (progressState.resourcesCreated) line.push(_('Resources: %d.', progressState.resourcesCreated)); + if (progressState.notesTagged) line.push(_('Tagged: %d.', progressState.notesTagged)); + lastProgress = line.join(' '); + this.addMessage('progress', lastProgress); + }, + onError: (error) => { + const messages = this.state.messages.slice(); + let s = error.trace ? error.trace : error.toString(); + messages.push({ key: 'error_' + (progressCount++), text: s }); + this.addMessage('error_' + (progressCount++), lastProgress); + }, + } + + // const folder = await Folder.save({ title: folderTitle }); + + // await importEnex(folder.id, filePath, options); + + this.addMessage('done', _('The notes have been imported: %s', lastProgress)); + this.setState({ doImport: false }); + } + + render() { + const theme = themeStyle(this.props.theme); + const style = this.props.style; + const messages = this.state.messages; + + const messagesStyle = { + padding: 10, + fontSize: theme.fontSize, + fontFamily: theme.fontFamily, + backgroundColor: theme.backgroundColor, + }; + + const headerStyle = { + width: style.width, + }; + + const messageComps = []; + for (let i = 0; i < messages.length; i++) { + messageComps.push(
{messages[i].text}
); + } + + return ( +
+
+
+ {messageComps} +
+
+ ); + } + +} + +const mapStateToProps = (state) => { + return { + theme: state.settings.theme, + }; +}; + +const ImportScreen = connect(mapStateToProps)(ImportScreenComponent); + +module.exports = { ImportScreen }; \ No newline at end of file diff --git a/ElectronClient/app/gui/MainScreen.jsx b/ElectronClient/app/gui/MainScreen.jsx index e7654d6d4..f666cd4df 100644 --- a/ElectronClient/app/gui/MainScreen.jsx +++ b/ElectronClient/app/gui/MainScreen.jsx @@ -24,6 +24,12 @@ class MainScreenComponent extends React.Component { }); } + componentWillReceiveProps(newProps) { + if (newProps.windowCommand) { + this.doCommand(newProps.windowCommand); + } + } + toggleVisiblePanes() { let panes = this.state.noteVisiblePanes.slice(); if (panes.length === 2) { @@ -37,6 +43,84 @@ class MainScreenComponent extends React.Component { this.setState({ noteVisiblePanes: panes }); } + doCommand(command) { + if (!command) return; + + const createNewNote = async (title, isTodo) => { + const folderId = Setting.value('activeFolderId'); + if (!folderId) return; + + const note = await Note.save({ + title: title, + parent_id: folderId, + is_todo: isTodo ? 1 : 0, + }); + Note.updateGeolocation(note.id); + + this.props.dispatch({ + type: 'NOTE_SELECT', + id: note.id, + }); + } + + let commandProcessed = true; + + if (command.name === 'newNote') { + this.setState({ + promptOptions: { + message: _('Note title:'), + onClose: async (answer) => { + if (answer) await createNewNote(answer, false); + this.setState({ promptOptions: null }); + } + }, + }); + } else if (command.name === 'newTodo') { + this.setState({ + promptOptions: { + message: _('To-do title:'), + onClose: async (answer) => { + if (answer) await createNewNote(answer, true); + this.setState({ promptOptions: null }); + } + }, + }); + } else if (command.name === 'newNotebook') { + this.setState({ + promptOptions: { + message: _('Notebook title:'), + onClose: async (answer) => { + if (answer) { + let folder = null; + try { + folder = await Folder.save({ title: answer }, { userSideValidation: true }); + } catch (error) { + bridge().showErrorMessageBox(error.message); + return; + } + + this.props.dispatch({ + type: 'FOLDER_SELECT', + id: folder.id, + }); + } + + this.setState({ promptOptions: null }); + } + }, + }); + } else { + commandProcessed = false; + } + + if (commandProcessed) { + this.props.dispatch({ + type: 'WINDOW_COMMAND', + name: null, + }); + } + } + render() { const style = this.props.style; const theme = themeStyle(this.props.theme); @@ -74,85 +158,24 @@ class MainScreenComponent extends React.Component { height: style.height, }; - const createNewNote = async (title, isTodo) => { - const folderId = Setting.value('activeFolderId'); - if (!folderId) return; - - const note = await Note.save({ - title: title, - parent_id: folderId, - is_todo: isTodo ? 1 : 0, - }); - Note.updateGeolocation(note.id); - - this.props.dispatch({ - type: 'NOTE_SELECT', - id: note.id, - }); - } - const headerButtons = []; headerButtons.push({ title: _('New note'), iconName: 'fa-file-o', - onClick: () => { - this.setState({ - promptOptions: { - message: _('Note title:'), - onClose: async (answer) => { - if (answer) await createNewNote(answer, false); - this.setState({ promptOptions: null }); - } - }, - }); - }, + onClick: () => { this.doCommand({ name: 'newNote' }) }, }); headerButtons.push({ title: _('New to-do'), iconName: 'fa-check-square-o', - onClick: () => { - this.setState({ - promptOptions: { - message: _('Note title:'), - onClose: async (answer) => { - if (answer) await createNewNote(answer, true); - this.setState({ promptOptions: null }); - } - }, - }); - }, + onClick: () => { this.doCommand({ name: 'newTodo' }) }, }); headerButtons.push({ title: _('New notebook'), iconName: 'fa-folder-o', - onClick: () => { - this.setState({ - promptOptions: { - message: _('Notebook title:'), - onClose: async (answer) => { - if (answer) { - let folder = null; - try { - folder = await Folder.save({ title: answer }, { userSideValidation: true }); - } catch (error) { - bridge().showErrorMessageBox(error.message); - return; - } - - this.props.dispatch({ - type: 'FOLDER_SELECT', - id: folder.id, - }); - } - - this.setState({ promptOptions: null }); - } - }, - }); - }, + onClick: () => { this.doCommand({ name: 'newNotebook' }) }, }); headerButtons.push({ @@ -179,6 +202,7 @@ class MainScreenComponent extends React.Component { const mapStateToProps = (state) => { return { theme: state.settings.theme, + windowCommand: state.windowCommand, }; }; diff --git a/ElectronClient/app/gui/Navigator.jsx b/ElectronClient/app/gui/Navigator.jsx index 1fb1adc4c..db0220aad 100644 --- a/ElectronClient/app/gui/Navigator.jsx +++ b/ElectronClient/app/gui/Navigator.jsx @@ -1,5 +1,6 @@ const React = require('react'); const Component = React.Component; const { connect } = require('react-redux'); +const { app } = require('../app.js'); class NavigatorComponent extends Component { @@ -7,6 +8,7 @@ class NavigatorComponent extends Component { if (!this.props.route) throw new Error('Route must not be null'); const route = this.props.route; + const screenProps = route.props ? route.props : {}; const Screen = this.props.screens[route.routeName].screen; const screenStyle = { @@ -16,7 +18,7 @@ class NavigatorComponent extends Component { return (
- +
); } diff --git a/ElectronClient/app/gui/Root.jsx b/ElectronClient/app/gui/Root.jsx index 0326e8bfb..7661deff0 100644 --- a/ElectronClient/app/gui/Root.jsx +++ b/ElectronClient/app/gui/Root.jsx @@ -5,6 +5,7 @@ const { connect, Provider } = require('react-redux'); const { MainScreen } = require('./MainScreen.min.js'); const { OneDriveLoginScreen } = require('./OneDriveLoginScreen.min.js'); +const { ImportScreen } = require('./ImportScreen.min.js'); const { Navigator } = require('./Navigator.min.js'); const { app } = require('../app'); @@ -52,6 +53,7 @@ class RootComponent extends React.Component { const screens = { Main: { screen: MainScreen }, OneDriveLogin: { screen: OneDriveLoginScreen }, + Import: { screen: ImportScreen }, }; return ( diff --git a/ElectronClient/app/package-lock.json b/ElectronClient/app/package-lock.json index 1ffb48a48..908ef8018 100644 --- a/ElectronClient/app/package-lock.json +++ b/ElectronClient/app/package-lock.json @@ -1114,8 +1114,7 @@ "core-util-is": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", - "dev": true + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=" }, "create-error-class": { "version": "3.0.2", @@ -2261,8 +2260,7 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "dev": true + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { "version": "1.3.4", @@ -2490,8 +2488,7 @@ "isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", - "dev": true + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=" }, "isbinaryfile": { "version": "3.0.2", @@ -2611,6 +2608,11 @@ "verror": "1.10.0" } }, + "jssha": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-2.3.1.tgz", + "integrity": "sha1-FHshJTaQNcpLL30hDcU58Amz3po=" + }, "kind-of": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", @@ -2653,6 +2655,11 @@ "invert-kv": "1.0.0" } }, + "levenshtein": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/levenshtein/-/levenshtein-1.0.5.tgz", + "integrity": "sha1-ORFzepy1baNF0Aj1V4LG8TiXm6M=" + }, "linkify-it": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.0.3.tgz", @@ -3486,8 +3493,7 @@ "process-nextick-args": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", - "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=", - "dev": true + "integrity": "sha1-FQ4gt1ZZCtP5EJPyWk8q2L/zC6M=" }, "progress-stream": { "version": "1.2.0", @@ -3726,7 +3732,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.3.tgz", "integrity": "sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==", - "dev": true, "requires": { "core-util-is": "1.0.2", "inherits": "2.0.3", @@ -4899,6 +4904,20 @@ "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz", "integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM=" }, + "string-padding": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-padding/-/string-padding-1.0.2.tgz", + "integrity": "sha1-OqrYVbPpc1xeQS3+chmMz5nH9I4=" + }, + "string-to-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.0.tgz", + "integrity": "sha1-rPLJ6tHEGOFIUJoS0su0afMzohg=", + "requires": { + "inherits": "2.0.3", + "readable-stream": "2.3.3" + } + }, "string-width": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", @@ -4914,7 +4933,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.0.3.tgz", "integrity": "sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==", - "dev": true, "requires": { "safe-buffer": "5.1.1" } @@ -5270,8 +5288,7 @@ "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", - "dev": true + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" }, "uuid": { "version": "3.1.0", diff --git a/ElectronClient/app/package.json b/ElectronClient/app/package.json index 9386088ed..a1a8d2a39 100644 --- a/ElectronClient/app/package.json +++ b/ElectronClient/app/package.json @@ -31,6 +31,8 @@ "fs-extra": "^4.0.2", "highlight.js": "^9.12.0", "html-entities": "^1.2.1", + "jssha": "^2.3.1", + "levenshtein": "^1.0.5", "lodash": "^4.17.4", "markdown-it": "^8.4.0", "marked": "^0.3.6", @@ -48,6 +50,8 @@ "sharp": "^0.18.4", "sprintf-js": "^1.1.1", "sqlite3": "^3.1.13", + "string-padding": "^1.0.2", + "string-to-stream": "^1.1.0", "tcp-port-used": "^0.1.2" } } diff --git a/CliClient/app/import-enex-md-gen.js b/ReactNativeClient/lib/import-enex-md-gen.js similarity index 100% rename from CliClient/app/import-enex-md-gen.js rename to ReactNativeClient/lib/import-enex-md-gen.js diff --git a/CliClient/app/import-enex.js b/ReactNativeClient/lib/import-enex.js similarity index 99% rename from CliClient/app/import-enex.js rename to ReactNativeClient/lib/import-enex.js index 93d424a58..f10de89dc 100644 --- a/CliClient/app/import-enex.js +++ b/ReactNativeClient/lib/import-enex.js @@ -12,7 +12,7 @@ const { time } = require('lib/time-utils.js'); const Levenshtein = require('levenshtein'); const jsSHA = require("jssha"); -const Promise = require('promise'); +//const Promise = require('promise'); const fs = require('fs-extra'); const stringToStream = require('string-to-stream') diff --git a/ReactNativeClient/lib/models/folder.js b/ReactNativeClient/lib/models/folder.js index 45b9ddf11..a7d68d915 100644 --- a/ReactNativeClient/lib/models/folder.js +++ b/ReactNativeClient/lib/models/folder.js @@ -34,6 +34,19 @@ class Folder extends BaseItem { } } + static async findUniqueFolderTitle(title) { + let counter = 1; + let titleToTry = title; + while (true) { + const folder = await this.loadByField('title', titleToTry); + if (!folder) return titleToTry; + titleToTry = title + ' (' + counter + ')'; + counter++; + if (counter >= 100) titleToTry = title + ' (' + ((new Date()).getTime()) + ')'; + if (counter >= 1000) throw new Error('Cannot find unique title'); + } + } + static noteIds(parentId) { return this.db().selectAll('SELECT id FROM notes WHERE is_conflict = 0 AND parent_id = ?', [parentId]).then((rows) => { let output = [];