From 84d0ec432af91c4c5f761f599ca277e4be212f83 Mon Sep 17 00:00:00 2001 From: Yuya Ochiai Date: Thu, 20 Apr 2017 21:32:34 +0900 Subject: [PATCH] Implement simple spellchecker --- electron-builder.json | 3 +- package.json | 3 +- scripts/7zip-cli.js | 13 ++++ src/browser/components/MattermostView.jsx | 6 +- src/browser/components/SettingsPage.jsx | 47 ++++++++++++- src/browser/js/contextMenu.js | 26 +++++++ src/browser/settings.jsx | 5 +- src/browser/webview/mattermost.js | 9 +++ src/common/settings.js | 4 +- src/main.js | 35 ++++++++++ src/main/SpellChecker.js | 69 +++++++++++++++++++ src/package.json | 1 + test/.eslintrc.json | 4 +- test/specs/browser/settings_test.js | 18 +++++ test/specs/spellchecker_test.js | 82 +++++++++++++++++++++++ 15 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 scripts/7zip-cli.js create mode 100644 src/browser/js/contextMenu.js create mode 100644 src/main/SpellChecker.js create mode 100644 test/specs/spellchecker_test.js diff --git a/electron-builder.json b/electron-builder.json index 7af9e041..71d27014 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -9,7 +9,8 @@ "main_bundle.js", "browser/**/*{.html,.css,_bundle.js}", "assets/**/*", - "node_modules/bootstrap/dist/**" + "node_modules/bootstrap/dist/**", + "node_modules/simple-spellchecker/dict/*.dic" ], "deb": { "synopsis": "Mattermost" diff --git a/package.json b/package.json index 4e3e4211..a6baf4d1 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ "url": "git://github.com/mattermost/desktop.git" }, "scripts": { - "postinstall": "install-app-deps", + "postinstall": "install-app-deps && npm run extract-dict", + "extract-dict": "cd src/node_modules/simple-spellchecker/dict && node ../../../../scripts/7zip-cli.js e -y '*.zip'", "build": "npm-run-all build:*", "build:main": "cross-env NODE_ENV=production webpack --bail --config webpack.config.main.js", "build:renderer": "cross-env NODE_ENV=production webpack --bail --config webpack.config.renderer.js", diff --git a/scripts/7zip-cli.js b/scripts/7zip-cli.js new file mode 100644 index 00000000..40d734d2 --- /dev/null +++ b/scripts/7zip-cli.js @@ -0,0 +1,13 @@ +/* eslint-disable no-process-exit */ + +const {spawn} = require('child_process'); +const {path7za} = require('7zip-bin'); + +spawn(path7za, process.argv.slice(2), { + stdio: 'inherit' +}).on('error', (err) => { + console.error(err); + process.exit(1); +}).on('close', (code) => { + process.exit(code); +}); diff --git a/src/browser/components/MattermostView.jsx b/src/browser/components/MattermostView.jsx index 85c43424..0359753d 100644 --- a/src/browser/components/MattermostView.jsx +++ b/src/browser/components/MattermostView.jsx @@ -2,7 +2,7 @@ const React = require('react'); const {findDOMNode} = require('react-dom'); const {ipcRenderer, remote, shell} = require('electron'); const url = require('url'); -const electronContextMenu = require('electron-context-menu'); +const contextMenu = require('../js/contextMenu'); const ErrorView = require('./ErrorView.jsx'); @@ -79,9 +79,7 @@ const MattermostView = React.createClass({ // webview.openDevTools(); if (!this.state.isContextMenuAdded) { - electronContextMenu({ - window: webview - }); + contextMenu.setup(webview); this.setState({isContextMenuAdded: true}); } }); diff --git a/src/browser/components/SettingsPage.jsx b/src/browser/components/SettingsPage.jsx index 895c9a1f..1cbe4733 100644 --- a/src/browser/components/SettingsPage.jsx +++ b/src/browser/components/SettingsPage.jsx @@ -1,6 +1,6 @@ const React = require('react'); const ReactDOM = require('react-dom'); -const {Button, Checkbox, Col, FormGroup, Grid, HelpBlock, Navbar, Radio, Row} = require('react-bootstrap'); +const {Button, Checkbox, Col, FormControl, FormGroup, Grid, HelpBlock, Navbar, Radio, Row} = require('react-bootstrap'); const {ipcRenderer, remote} = require('electron'); const AutoLaunch = require('auto-launch'); @@ -112,7 +112,9 @@ const SettingsPage = React.createClass({ notifications: { flashWindow: this.state.notifications.flashWindow }, - showUnreadBadge: this.state.showUnreadBadge + showUnreadBadge: this.state.showUnreadBadge, + useSpellChecker: this.state.useSpellChecker, + spellCheckerLocale: this.state.spellCheckerLocale }; settings.writeFile(this.props.configFile, config, (err) => { @@ -209,6 +211,20 @@ const SettingsPage = React.createClass({ setImmediate(this.startSaveConfig); }, + handleChangeUseSpellChecker() { + this.setState({ + useSpellChecker: !this.refs.useSpellChecker.props.checked + }); + setImmediate(this.startSaveConfig); + }, + + handleChangeSpellCheckerLocale(event) { + this.setState({ + spellCheckerLocale: event.target.value + }); + setImmediate(this.startSaveConfig); + }, + updateTeam(index, newData) { var teams = this.state.teams; teams[index] = newData; @@ -362,6 +378,33 @@ const SettingsPage = React.createClass({ ); } + options.push( + + {'Check spelling'} + + {'Highlight misspelled words in your messages.'} + {' Available for English, French, German, and Dutch.'} + + + + + + + + ); + const settingsPage = { navbar: { backgroundColor: '#fff' diff --git a/src/browser/js/contextMenu.js b/src/browser/js/contextMenu.js new file mode 100644 index 00000000..99afddf1 --- /dev/null +++ b/src/browser/js/contextMenu.js @@ -0,0 +1,26 @@ +const {ipcRenderer} = require('electron'); +const electronContextMenu = require('electron-context-menu'); + +function getSuggestionsMenus(win, suggestions) { + return suggestions.map((s) => ({ + label: s, + click() { + (win.webContents || win.getWebContents()).replaceMisspelling(s); + } + })); +} + +module.exports = { + setup(win) { + electronContextMenu({ + window: win, + prepend(params) { + if (params.isEditable && params.misspelledWord !== '') { + const suggestions = ipcRenderer.sendSync('get-spelling-suggestions', params.misspelledWord); + return getSuggestionsMenus(win, suggestions); + } + return []; + } + }); + } +}; diff --git a/src/browser/settings.jsx b/src/browser/settings.jsx index 2fc71e63..ec1a9867 100644 --- a/src/browser/settings.jsx +++ b/src/browser/settings.jsx @@ -9,12 +9,11 @@ const {remote} = require('electron'); const React = require('react'); const ReactDOM = require('react-dom'); const SettingsPage = require('./components/SettingsPage.jsx'); +const contextMenu = require('./js/contextMenu'); const configFile = remote.app.getPath('userData') + '/config.json'; -require('electron-context-menu')({ - window: remote.getCurrentWindow() -}); +contextMenu.setup(remote.getCurrentWindow()); ReactDOM.render( , diff --git a/src/browser/webview/mattermost.js b/src/browser/webview/mattermost.js index 8eb0266c..7350621c 100644 --- a/src/browser/webview/mattermost.js +++ b/src/browser/webview/mattermost.js @@ -2,6 +2,7 @@ const electron = require('electron'); const ipc = electron.ipcRenderer; +const webFrame = electron.webFrame; const notification = require('../js/notification'); Reflect.deleteProperty(global.Buffer); // http://electron.atom.io/docs/tutorial/security/#buffer-global @@ -146,3 +147,11 @@ notification.override({ ipc.sendToHost('onNotificationClick'); } }); + +const spellCheckerLocale = ipc.sendSync('get-spellchecker-locale'); +webFrame.setSpellCheckProvider(spellCheckerLocale, false, { + spellCheck(text) { + const res = ipc.sendSync('checkspell', text); + return res === null ? true : res; + } +}); diff --git a/src/common/settings.js b/src/common/settings.js index f19cfcae..7f001b35 100644 --- a/src/common/settings.js +++ b/src/common/settings.js @@ -23,7 +23,9 @@ function loadDefault(version) { notifications: { flashWindow: 0 // 0 = flash never, 1 = only when idle (after 10 seconds), 2 = always }, - showUnreadBadge: true + showUnreadBadge: true, + useSpellChecker: false, + spellCheckerLocale: 'en-US' }; default: return {}; diff --git a/src/main.js b/src/main.js index a1ea01fb..47e2fd88 100644 --- a/src/main.js +++ b/src/main.js @@ -63,11 +63,14 @@ const appMenu = require('./main/menus/app'); const trayMenu = require('./main/menus/tray'); const allowProtocolDialog = require('./main/allowProtocolDialog'); +const SpellChecker = require('./main/SpellChecker'); + const assetsDir = path.resolve(app.getAppPath(), 'assets'); // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. var mainWindow = null; +let spellChecker = null; var argv = require('yargs').parse(process.argv.slice(1)); @@ -99,6 +102,7 @@ try { ipcMain.on('update-config', () => { const configFile = app.getPath('userData') + '/config.json'; config = settings.readFileSync(configFile); + ipcMain.emit('update-dict', true, config.spellCheckerLocale); }); // Only for OS X @@ -447,6 +451,37 @@ app.on('ready', () => { }); ipcMain.emit('update-menu', true, config); + ipcMain.on('update-dict', (event, locale) => { + if (config.useSpellChecker) { + spellChecker = new SpellChecker( + locale, + path.resolve(app.getAppPath(), 'node_modules/simple-spellchecker/dict'), + (err) => { + if (err) { + console.error(err); + } + }); + } + }); + ipcMain.on('checkspell', (event, word) => { + let res = null; + if (config.useSpellChecker && spellChecker.isReady() && word !== null) { + res = spellChecker.spellCheck(word); + } + event.returnValue = res; + }); + ipcMain.on('get-spelling-suggestions', (event, word) => { + if (config.useSpellChecker && spellChecker.isReady() && word !== null) { + event.returnValue = spellChecker.getSuggestions(word, 10); + } else { + event.returnValue = []; + } + }); + ipcMain.on('get-spellchecker-locale', (event) => { + event.returnValue = config.spellCheckerLocale; + }); + ipcMain.emit('update-dict', true, config.spellCheckerLocale); + // Open the DevTools. // mainWindow.openDevTools(); }); diff --git a/src/main/SpellChecker.js b/src/main/SpellChecker.js new file mode 100644 index 00000000..115a5b3d --- /dev/null +++ b/src/main/SpellChecker.js @@ -0,0 +1,69 @@ +'use strict'; + +const simpleSpellChecker = require('simple-spellchecker'); + +/// Following approach for contractions is derived from electron-spellchecker. + +// NB: This is to work around electron/electron#1005, where contractions +// are incorrectly marked as spelling errors. This lets people get away with +// incorrectly spelled contracted words, but it's the best we can do for now. +const contractions = [ + "ain't", "aren't", "can't", "could've", "couldn't", "couldn't've", "didn't", "doesn't", "don't", "hadn't", + "hadn't've", "hasn't", "haven't", "he'd", "he'd've", "he'll", "he's", "how'd", "how'll", "how's", "I'd", + "I'd've", "I'll", "I'm", "I've", "isn't", "it'd", "it'd've", "it'll", "it's", "let's", "ma'am", "mightn't", + "mightn't've", "might've", "mustn't", "must've", "needn't", "not've", "o'clock", "shan't", "she'd", "she'd've", + "she'll", "she's", "should've", "shouldn't", "shouldn't've", "that'll", "that's", "there'd", "there'd've", + "there're", "there's", "they'd", "they'd've", "they'll", "they're", "they've", "wasn't", "we'd", "we'd've", + "we'll", "we're", "we've", "weren't", "what'll", "what're", "what's", "what've", "when's", "where'd", + "where's", "where've", "who'd", "who'll", "who're", "who's", "who've", "why'll", "why're", "why's", "won't", + "would've", "wouldn't", "wouldn't've", "y'all", "y'all'd've", "you'd", "you'd've", "you'll", "you're", "you've" +]; + +const contractionMap = contractions.reduce((acc, word) => { + acc[word.replace(/'.*/, '')] = true; + return acc; +}, {}); + +/// End: derived from electron-spellchecker. + +class SpellChecker { + constructor(locale, dictDir, callback) { + this.dict = null; + this.locale = locale; + simpleSpellChecker.getDictionary(locale, dictDir, (err, dict) => { + if (err) { + if (callback) { + callback(err); + } + } else { + this.dict = dict; + if (callback) { + callback(null, this); + } + } + }); + } + + isReady() { + return this.dict !== null; + } + + spellCheck(word) { + if (word.toLowerCase() === 'mattermost') { + return true; + } + if (isFinite(word)) { // Numerals are not included in the dictionary + return true; + } + if (this.locale.match(/^en-?/) && contractionMap[word]) { + return true; + } + return this.dict.spellCheck(word); + } + + getSuggestions(word, maxSuggestions) { + return this.dict.getSuggestions(word, maxSuggestions); + } +} + +module.exports = SpellChecker; diff --git a/src/package.json b/src/package.json index 9682da18..0b2e3c0c 100644 --- a/src/package.json +++ b/src/package.json @@ -22,6 +22,7 @@ "react-addons-css-transition-group": "^15.4.2", "react-bootstrap": "~0.30.7", "react-dom": "^15.4.2", + "simple-spellchecker": "git://github.com/jfmdev/simple-spellchecker.git#723062952a0290c6285aeaf02f14d9c74c41cadb", "underscore": "^1.8.3", "yargs": "^3.32.0" } diff --git a/test/.eslintrc.json b/test/.eslintrc.json index 836966a6..72980a57 100644 --- a/test/.eslintrc.json +++ b/test/.eslintrc.json @@ -6,9 +6,11 @@ "open_window": true }, "rules": { + "func-names": 0, "global-require": 0, "max-nested-callbacks": 0, "no-eval": 0, - "no-magic-numbers": 0 + "no-magic-numbers": 0, + "prefer-arrow-callback": 0 } } diff --git a/test/specs/browser/settings_test.js b/test/specs/browser/settings_test.js index 10278373..71591826 100644 --- a/test/specs/browser/settings_test.js +++ b/test/specs/browser/settings_test.js @@ -200,6 +200,24 @@ describe('browser/settings.html', function desc() { isExisting('#inputShowUnreadBadge').then((existing) => existing.should.equal(expected)); }); }); + + describe('Check spelling', () => { + it('should appear and be selectable', () => { + env.addClientCommands(this.app.client); + return this.app.client. + loadSettingsPage(). + isExisting('#selectSpellCheckerLocale').then((existing) => existing.should.equal(true)). + scroll('#selectSpellCheckerLocale'). + click('#inputSpellChecker'). + element('#selectSpellCheckerLocale').selectByVisibleText('French'). + pause(700). + then(() => { + const config1 = JSON.parse(fs.readFileSync(env.configFilePath, 'utf-8')); + config1.useSpellChecker.should.equal(true); + config1.spellCheckerLocale.should.equal('fr-FR'); + }); + }); + }); }); describe('RemoveServerModal', () => { diff --git a/test/specs/spellchecker_test.js b/test/specs/spellchecker_test.js new file mode 100644 index 00000000..e653e88b --- /dev/null +++ b/test/specs/spellchecker_test.js @@ -0,0 +1,82 @@ +const SpellChecker = require('../../src/main/SpellChecker'); +const path = require('path'); + +describe('main/Spellchecker.js', function() { + describe('en-US', function() { + let spellchecker = null; + + before(function(done) { + spellchecker = new SpellChecker( + 'en-US', + path.resolve(__dirname, '../../src/node_modules/simple-spellchecker/dict'), + done + ); + }); + + it('should spellcheck', function() { + // https://github.com/jfmdev/simple-spellchecker/issues/3 + spellchecker.spellCheck('spell').should.equal(true); + spellchecker.spellCheck('spel').should.equal(false); + spellchecker.spellCheck('December').should.equal(true); + spellchecker.spellCheck('december').should.equal(true); + spellchecker.spellCheck('English').should.equal(true); + spellchecker.spellCheck('Japan').should.equal(true); + }); + + it('should allow contractions', function() { + spellchecker.spellCheck("shouldn't").should.equal(true); + spellchecker.spellCheck('shouldn').should.equal(true); + }); + + it('should allow numerals', function() { + spellchecker.spellCheck('1').should.equal(true); + spellchecker.spellCheck('-100').should.equal(true); + spellchecker.spellCheck('3.14').should.equal(true); + }); + + it('should allow "Mattermost"', function() { + spellchecker.spellCheck('Mattermost').should.equal(true); + spellchecker.spellCheck('mattermost').should.equal(true); + }); + }); + + describe('en-GB', function() { + let spellchecker = null; + + before(function(done) { + spellchecker = new SpellChecker( + 'en-GB', + path.resolve(__dirname, '../../src/node_modules/simple-spellchecker/dict'), + done + ); + }); + + it('should allow contractions', function() { + spellchecker.spellCheck("shouldn't").should.equal(true); + spellchecker.spellCheck('shouldn').should.equal(true); + }); + }); + + describe('de-DE', function() { + let spellchecker = null; + + before(function(done) { + spellchecker = new SpellChecker( + 'de-DE', + path.resolve(__dirname, '../../src/node_modules/simple-spellchecker/dict'), + done + ); + }); + + it('should spellcheck', function() { + spellchecker.spellCheck('Guten').should.equal(true); + spellchecker.spellCheck('tag').should.equal(true); + }); + + it('should allow numerals', function() { + spellchecker.spellCheck('1').should.equal(true); + spellchecker.spellCheck('-100').should.equal(true); + spellchecker.spellCheck('3.14').should.equal(true); + }); + }); +});