diff --git a/README.md b/README.md index 6010f6f..0682847 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ Origami is an open-source research tool focused on graphical representations of ## Download -| [](https://github.com/aMarcireau/origami/releases/download/v0.8.1/Origami-linux-x64.zip) | [](https://github.com/aMarcireau/origami/releases/download/v0.8.1/Origami-darwin-x64.zip) | [](https://github.com/aMarcireau/origami/releases/download/v0.8.1/Origami-win32-x64.zip) | +| [](https://github.com/aMarcireau/origami/releases/download/v0.9.0/Origami-linux-x64.zip) | [](https://github.com/aMarcireau/origami/releases/download/v0.9.0/Origami-darwin-x64.zip) | [](https://github.com/aMarcireau/origami/releases/download/v0.9.0/Origami-win32-x64.zip) | | ------------------------------------ | ------------------------------------ | ---------------------------------------| -| [Download for Linux](https://github.com/aMarcireau/origami/releases/download/v0.8.1/Origami-linux-x64.zip) | [Download for macOS](https://github.com/aMarcireau/origami/releases/download/v0.8.1/Origami-darwin-x64.zip) | [Download for Windows](https://github.com/aMarcireau/origami/releases/download/v0.8.1/Origami-win32-x64.zip) | +| [Download for Linux](https://github.com/aMarcireau/origami/releases/download/v0.9.0/Origami-linux-x64.zip) | [Download for macOS](https://github.com/aMarcireau/origami/releases/download/v0.9.0/Origami-darwin-x64.zip) | [Download for Windows](https://github.com/aMarcireau/origami/releases/download/v0.9.0/Origami-win32-x64.zip) | Visit the [Releases](https://github.com/aMarcireau/origami/releases) page to download Origami for other platforms. @@ -42,7 +42,7 @@ Origami can load several DOIs at once from a [JSON](https://www.json.org) format Origami saves and load collections in [JSON](https://www.json.org) format. The generated files have the following structure: ```yaml { - "appVersion": "0.8.1", # the version of the app used to generate this save + "appVersion": "0.9.0", # the version of the app used to generate this save "display": 0, # the current display's index (0 for graph, 1 for list) "knownDois": [ # list of DOIs clicked at least once (used to highlight new publications) "10.1109/tpami.2016.2574707" @@ -105,7 +105,6 @@ Origami saves and load collections in [JSON](https://www.json.org) format. The g "xOffset": 0, # the current graph positions' x coordinate, float "yOffset": 0, # the current graph positions' y coordinate, float "sticky": false # false means that the 'sticky' box is unchecked - }, "publications": [ # list of known publications [ # each publication is a two-elements list containg a string and an object diff --git a/package-lock.json b/package-lock.json index caccb23..f6ae17b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,9 +28,9 @@ } }, "ajv": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.3.0.tgz", - "integrity": "sha1-RBT/dKUIecII7l/cgm4ywwNUnto=", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.0.tgz", + "integrity": "sha1-6yhAdG6dxIvV4GOjbj/UAMXqtak=", "requires": { "co": "4.6.0", "fast-deep-equal": "1.0.0", @@ -1765,6 +1765,11 @@ } } }, + "deep-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.0.1.tgz", + "integrity": "sha1-9dJgKStmDghO/0zbyfCK0yR0SLU=" + }, "deep-extend": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.4.2.tgz", @@ -3284,7 +3289,7 @@ "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", "requires": { - "ajv": "5.3.0", + "ajv": "5.5.0", "har-schema": "2.0.0" } }, @@ -5544,7 +5549,7 @@ "requires": { "acorn": "5.2.1", "acorn-dynamic-import": "2.0.2", - "ajv": "5.3.0", + "ajv": "5.5.0", "ajv-keywords": "2.1.1", "async": "2.6.0", "enhanced-resolve": "3.4.1", diff --git a/package.json b/package.json index e4885b2..e106461 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "private": true, "dependencies": { - "ajv": "5.3.0", + "ajv": "5.5.0", "archiver": "2.1.0", "babel-core": "6.26.0", "babel-loader": "7.1.2", @@ -15,6 +15,7 @@ "html-webpack-inline-source-plugin": "0.0.9", "html-webpack-plugin": "2.30.1", "htmlparser2": "3.9.2", + "deep-equal": "1.0.1", "radium": "0.19.6", "react": "16.0.0", "react-dom": "16.0.0", @@ -27,8 +28,8 @@ "webpack-merge": "4.1.1" }, "scripts": { - "start": "webpack --progress --colors --watch --config webpack.dev.js", - "build": "webpack --progress --colors --config webpack.prod.js", - "release": "node release.js" + "start": "node node_modules/webpack/bin/webpack.js --progress --colors --watch --config scripts/webpack.dev.js", + "build": "node node_modules/webpack/bin/webpack.js --progress --colors --config scripts/webpack.prod.js", + "release": "node scripts/release.js" } } diff --git a/recursive.js b/scripts/recursive.js similarity index 100% rename from recursive.js rename to scripts/recursive.js diff --git a/release.js b/scripts/release.js similarity index 92% rename from release.js rename to scripts/release.js index 1ed171f..d4b7bec 100644 --- a/release.js +++ b/scripts/release.js @@ -1,12 +1,12 @@ +const fs = require('fs'); +const path = require('path'); const request = require('request'); const readline = require('readline'); const stream = require('stream'); -const fs = require('fs'); const archiver = require('archiver'); const zlib = require('zlib'); const urlTemplate = require('url-template'); const recursive = require(`${__dirname}/recursive`); -const path = require('path'); function isSmallerThan(firstVersion, secondVersion) { if (firstVersion.major !== secondVersion.major) { @@ -28,7 +28,7 @@ function areEqual(firstVersion, secondVersion) { const semver = /^v([1-9]\d*|0)\.([1-9]\d*|0)\.([1-9]\d*|0)(?:-([0-9A-Za-z-\.]+)$|$)/; -const versionAsString = JSON.parse(fs.readFileSync(`${__dirname}/source/package.json`)).version; +const versionAsString = JSON.parse(fs.readFileSync(`${path.dirname(__dirname)}/source/package.json`)).version; const semverMatch = semver.exec(`v${versionAsString}`); if (!semverMatch) { console.error('The version number does not have the expected format'); @@ -44,7 +44,7 @@ const version = { // check the links' version in README.md if (version.identifier == null) { const simpleSemver = /([1-9]\d*|0)\.([1-9]\d*|0)\.([1-9]\d*|0)/g; - const readme = fs.readFileSync(`${__dirname}/README.md`).toString('utf8'); + const readme = fs.readFileSync(`${path.dirname(__dirname)}/README.md`).toString('utf8'); for (;;) { const simpleSemverMatch = simpleSemver.exec(readme); if (simpleSemverMatch == null) { @@ -130,20 +130,20 @@ usernameInterface.question('username: ', username => { process.exit(1); } try { - fs.mkdirSync(`${__dirname}/build/archives`); + fs.mkdirSync(`${path.dirname(__dirname)}/build/archives`); } catch (error) {} let directoriesLeft = 0; - for (const directoryToZip of fs.readdirSync(`${__dirname}/build`)) { - if (fs.lstatSync(`${__dirname}/build/${directoryToZip}`).isDirectory()) { + for (const directoryToZip of fs.readdirSync(`${path.dirname(__dirname)}/build`)) { + if (fs.lstatSync(`${path.dirname(__dirname)}/build/${directoryToZip}`).isDirectory()) { const matchedConfiguration = /^Origami-([^-]+)-([^-]+)$/.exec(directoryToZip); if (matchedConfiguration != null) { ++directoriesLeft; - const output = fs.createWriteStream(`${__dirname}/build/archives/${directoryToZip}.zip`); + const output = fs.createWriteStream(`${path.dirname(__dirname)}/build/archives/${directoryToZip}.zip`); output.on('close', () => { - console.log(`Created '${__dirname}/build/archives/${directoryToZip}.zip'`); - fs.readFile(`${__dirname}/build/archives/${directoryToZip}.zip`, (error, archive) => { + console.log(`Created '${path.dirname(__dirname)}/build/archives/${directoryToZip}.zip'`); + fs.readFile(`${path.dirname(__dirname)}/build/archives/${directoryToZip}.zip`, (error, archive) => { if (error) { - console.error(`${__dirname}/build/archives/${directoryToZip}.zip`, error); + console.error(`${path.dirname(__dirname)}/build/archives/${directoryToZip}.zip`, error); process.exit(1); } request.post({ @@ -338,17 +338,17 @@ usernameInterface.question('username: ', username => { // resolve Framework symlinks manually before zipping try { - recursive.rmSync(`${__dirname}/build/archives/${directoryToZip}`); - fs.mkdirSync(`${__dirname}/build/archives`); + recursive.rmSync(`${path.dirname(__dirname)}/build/archives/${directoryToZip}`); + fs.mkdirSync(`${path.dirname(__dirname)}/build/archives`); } catch (error) {} try { - fs.mkdirSync(`${__dirname}/build/archives/${directoryToZip}`); + fs.mkdirSync(`${path.dirname(__dirname)}/build/archives/${directoryToZip}`); } catch (error) {} recursive.copyFileSync( - `${__dirname}/build/${directoryToZip}/Origami.app`, - `${__dirname}/build/archives/${directoryToZip}/Origami.app` + `${path.dirname(__dirname)}/build/${directoryToZip}/Origami.app`, + `${path.dirname(__dirname)}/build/archives/${directoryToZip}/Origami.app` ); - const frameworks = `${__dirname}/build/archives/${directoryToZip}/Origami.app/Contents/Frameworks`; + const frameworks = `${path.dirname(__dirname)}/build/archives/${directoryToZip}/Origami.app/Contents/Frameworks`; for (const frameworkFile of fs.readdirSync(frameworks)) { if (path.extname(frameworkFile) === '.framework') { const framework = `${frameworks}/${frameworkFile}`; @@ -362,10 +362,10 @@ usernameInterface.question('username: ', username => { } } - archive.directory(`${__dirname}/build/archives/${directoryToZip}/Origami.app`, 'Origami.app'); + archive.directory(`${path.dirname(__dirname)}/build/archives/${directoryToZip}/Origami.app`, 'Origami.app'); archive.finalize(); } else { - archive.directory(`${__dirname}/build/${directoryToZip}`, 'Origami'); + archive.directory(`${path.dirname(__dirname)}/build/${directoryToZip}`, 'Origami'); archive.finalize(); } } diff --git a/scripts/webpack.common.js b/scripts/webpack.common.js new file mode 100644 index 0000000..d72b793 --- /dev/null +++ b/scripts/webpack.common.js @@ -0,0 +1,77 @@ +const fs = require('fs'); +const path = require('path'); +const webpack = require('webpack'); +const child_process = require('child_process'); +const packager = require('electron-packager'); +const recursive = require(`${__dirname}/recursive`); + +module.exports = { + + /// package uses electron-packager to convert the output of webpack to actual apps. + package: (production, callback) => { + console.log('\nCopying the package assets'); + try { + fs.mkdirSync(`${path.dirname(path.dirname(__dirname))}/build/origami`); + } catch (error) {} + fs.copyFileSync(`${path.dirname(__dirname)}/source/package.json`, `${path.dirname(__dirname)}/build/origami/package.json`); + fs.writeFileSync(`${path.dirname(__dirname)}/build/origami/main.js`, `process.env.ORIGAMI_ENV = '${production ? 'production' : 'development'}';\n`); + fs.appendFileSync(`${path.dirname(__dirname)}/build/origami/main.js`, fs.readFileSync(`${path.dirname(__dirname)}/source/main.js`)); + fs.copyFileSync(`${path.dirname(__dirname)}/themes/default.json`, `${path.dirname(__dirname)}/build/origami/colors.json`); + try { + fs.mkdirSync(`${path.dirname(__dirname)}/build/origami/fonts`); + } catch (error) {} + recursive.copyFileSync(`${path.dirname(__dirname)}/fonts`, `${path.dirname(__dirname)}/build/origami/fonts`); + fs.copyFileSync(`${path.dirname(__dirname)}/icons/origami.png`, `${path.dirname(__dirname)}/build/origami/origami.png`); + fs.copyFileSync(`${path.dirname(__dirname)}/build/index.html`, `${path.dirname(__dirname)}/build/origami/index.html`); + child_process.execSync('npm install', {cwd: `${path.dirname(__dirname)}/build/origami`}, {stdio: 'inherit'}); + packager({ + dir: `${path.dirname(__dirname)}/build/origami`, + all: production, + out: `${path.dirname(__dirname)}/build`, + overwrite: true, + icon: `${path.dirname(__dirname)}/icons/origami`, + download: { + cache: `${path.dirname(__dirname)}/build/cache`, + }, + }, (error, appPaths) => { + if (error) { + console.error(error); + } else { + console.log(appPaths.map(appPath => `Created ${appPath}`).join('\n')); + } + callback(); + }); + }, + + /// configuration defines common webpack parameters for dev and prod. + configuration: { + target: 'electron-renderer', + entry: `${path.dirname(__dirname)}/source/app.js`, + output: { + path: `${path.dirname(__dirname)}/build`, + filename: 'index.js', + }, + module: { + loaders: [ + { + test: /\.js$/, + loader: 'babel-loader', + exclude: /node_modules/, + query: { + presets: [ + ['env', {targets: {electron: '1.7.6'}}], + 'react', + ], + plugins: [ + 'babel-plugin-transform-class-properties', + 'babel-plugin-transform-object-rest-spread', + ], + }, + }, + ], + }, + resolve: { + modules: [`${path.dirname(__dirname)}/node_modules`], + }, + }, +}; diff --git a/webpack.dev.js b/scripts/webpack.dev.js similarity index 79% rename from webpack.dev.js rename to scripts/webpack.dev.js index 8ad7362..e6fd4d8 100644 --- a/webpack.dev.js +++ b/scripts/webpack.dev.js @@ -1,6 +1,6 @@ -const fs = require('fs'); +const path = require('path'); const merge = require('webpack-merge'); -const common = require('./webpack.common'); +const common = require(`${__dirname}/webpack.common`); const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin'); @@ -15,16 +15,14 @@ module.exports = merge(common.configuration, { callback(); } else { this.packaged = true; - common.package(false, () => { - callback(); - }); + common.package(false, callback); } }); callback(); }); }, new HtmlWebpackPlugin({ - template: './source/index.ejs', + template: `${path.dirname(__dirname)}/source/index.ejs`, inlineSource: '\.js$', inject: true }), diff --git a/webpack.prod.js b/scripts/webpack.prod.js similarity index 78% rename from webpack.prod.js rename to scripts/webpack.prod.js index 88cc469..59643f8 100644 --- a/webpack.prod.js +++ b/scripts/webpack.prod.js @@ -1,6 +1,7 @@ +const path = require('path'); const webpack = require('webpack'); const merge = require('webpack-merge'); -const common = require('./webpack.common'); +const common = require(`${__dirname}/webpack.common`); const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackInlineSourcePlugin = require('html-webpack-inline-source-plugin'); @@ -8,9 +9,10 @@ module.exports = merge(common.configuration, { plugins: [ function() { this.plugin('before-run', (compiler, callback) => { - compiler.plugin('done', () => { - common.package(true); + compiler.plugin('after-emit', (compilation, callback) => { + common.package(true, callback); }); + callback(); }); }, @@ -18,7 +20,7 @@ module.exports = merge(common.configuration, { 'process.env.NODE_ENV': JSON.stringify('production'), }), new HtmlWebpackPlugin({ - template: './source/index.ejs', + template: `${path.dirname(__dirname)}/source/index.ejs`, inlineSource: '\.js$', inject: true, minify: { diff --git a/source/actions/managePublication.js b/source/actions/managePublication.js index 07f0c30..d5ce6e6 100644 --- a/source/actions/managePublication.js +++ b/source/actions/managePublication.js @@ -5,6 +5,7 @@ import { REMOVE_PUBLICATION, UPDATE_PUBLICATION, UPDATE_ALL_PUBLICATIONS, + SET_PUBLICATION_TAG, } from '../constants/actionTypes' export function addPublicationToCollection(doi, timestamp) { @@ -47,3 +48,11 @@ export function updateAllPublications(timestamp) { timestamp, }; } + +export function setPublicationTag(doi, tag) { + return { + type: SET_PUBLICATION_TAG, + doi, + tag, + }; +} diff --git a/source/app.js b/source/app.js index 0e44eaf..5278087 100644 --- a/source/app.js +++ b/source/app.js @@ -11,21 +11,14 @@ import origamiReducers from './reducers/origami' import origamiActors from './actors/origami' import Origami from './containers/Origami' import {disconnect} from './actions/setConnection' -import {jsonToState} from './state' +import { + jsonToState, + stateToJson +} from './state' import {SCHOLAR_STATUS_IDLE} from './constants/enums' -ipcRenderer.once('startWithState', (event, json, appVersion, colors) => { - - // retrieve the preloaded state - let [error, preloadedState] = jsonToState(json, null, null); - if (error != null && json != null) { - console.error(error); - } - if (preloadedState == null) { - preloadedState = {}; - } - preloadedState.appVersion = appVersion; - preloadedState.colors = JSON.parse(colors); +/// boot starts the app with the given default state. +function boot(preloadedState) { // create the store const store = createStore( @@ -47,4 +40,99 @@ ipcRenderer.once('startWithState', (event, json, appVersion, colors) => { // dispatch the disconnect action to initialize the app store.dispatch(disconnect()); +} + +ipcRenderer.once('startWithState', (event, json, appVersion, colors) => { + if (json) { + const [error, modified, preloadedState] = jsonToState(json, null, null); + if (error) { + ipcRenderer.once('backedup', (event, moveFailed, saveFailed, filename) => { + if (moveFailed) { + boot({ + appVersion, + colors: JSON.parse(colors), + warnings: { + list: [ + { + title: 'Loading the auto-save failed', + subtitle: 'Creating a backup failed as well', + level: 'error', + }, + ], + hash: 0, + }, + tab: 2, + }); + } else { + boot({ + appVersion, + colors: JSON.parse(colors), + warnings: { + list: [ + { + title: 'Loading the auto-save failed', + subtitle: `A backup was saved to '${filename}'`, + level: 'error', + }, + ], + hash: 0, + }, + tabs: 2, + }); + } + }); + ipcRenderer.send('backup', null, JSON.stringify({publications: []})); + } else if (modified) { + ipcRenderer.once('backedup', (event, moveFailed, saveFailed, filename) => { + if (moveFailed) { + boot({ + ...preloadedState, + appVersion, + colors: JSON.parse(colors), + warnings: { + ...preloadedState.warnings, + list: [ + ...preloadedState.warnings.list, + { + title: 'The auto-save was updated', + subtitle: 'Creating a backup failed', + level: 'error', + }, + ], + }, + tabs: 2, + }); + } else { + boot({ + ...preloadedState, + appVersion, + colors: JSON.parse(colors), + warnings: { + ...preloadedState.warnings, + list: [ + ...preloadedState.warnings.list, + { + title: 'The auto-save was updated', + subtitle: `A backup was saved to '${filename}'`, + level: 'warning', + }, + ], + }, + }); + } + }); + ipcRenderer.send('backup', null, stateToJson(preloadedState, false)); + } else { + boot({ + ...preloadedState, + appVersion, + colors: JSON.parse(colors), + }); + } + } else { + boot({ + appVersion, + colors: JSON.parse(colors), + }); + } }); diff --git a/source/constants/actionTypes.js b/source/constants/actionTypes.js index 40c2be9..3ecd3e6 100644 --- a/source/constants/actionTypes.js +++ b/source/constants/actionTypes.js @@ -24,6 +24,7 @@ export const UNSELECT_PUBLICATION = 'UNSELECT_PUBLICATION'; export const REMOVE_PUBLICATION = 'REMOVE_PUBLICATION'; export const UPDATE_PUBLICATION = 'UPDATE_PUBLICATION'; export const UPDATE_ALL_PUBLICATIONS = 'UPDATE_ALL_PUBLICATIONS'; +export const SET_PUBLICATION_TAG = 'SET_PUBLICATION_TAG'; export const RESOLVE_SCHOLAR_INITIAL_REQUEST = 'RESOLVE_SCHOLAR_INITIAL_REQUEST'; export const RESOLVE_SCHOLAR_CITERS_REQUEST = 'RESOLVE_SCHOLAR_CITERS_REQUEST'; diff --git a/source/containers/Graph.js b/source/containers/Graph.js index 7fea773..c614cf0 100644 --- a/source/containers/Graph.js +++ b/source/containers/Graph.js @@ -94,12 +94,14 @@ class Graph extends React.Component { if ( publication.status !== node.status || publication.selected !== node.selected + || publication.tag !== node.tag || known !== node.known || publication.locked !== node.locked ) { updateRequired = true; node.status = publication.status; node.selected = publication.selected; + node.tag = publication.tag; node.known = known; if (node.locked && !publication.locked) { node.fx = null; @@ -260,14 +262,16 @@ class Graph extends React.Component { // create 'enter' nodes const d3NodeGroup = this.d3Node.enter().append('g'); d3NodeGroup.filter(node => node.locked).append('circle') - .attr('r', 23) + .attr('r', 30) .attr('class', 'locked') .attr('fill', 'none') - .attr('stroke-width', 2) + .attr('stroke-width', 4) .attr('stroke', this.props.colors.secondaryContent) ; d3NodeGroup.append('circle') .attr('class', 'publication') + .attr('r', 22) + .attr('stroke-width', 4) ; d3NodeGroup.append('text') .attr('text-anchor', 'middle') @@ -309,8 +313,7 @@ class Graph extends React.Component { const colors = this.props.colors; this.d3Node.selectAll('circle.publication') .style('cursor', 'pointer') - .attr('fill', node => ( - node.selected ? + .attr('fill', node => (node.selected ? this.props.colors.active : (node.isCiting ? this.props.colors.valid @@ -323,26 +326,66 @@ class Graph extends React.Component { ) ) )) - .attr('r', node => node.known ? '20' : '19') - .attr('stroke', this.props.colors.active) - .attr('stroke-width', node => node.known ? '0' : '2') + .attr('stroke', node => (node.tag === null ? + (node.known ? + (node.selected ? + colors.active + : (node.isCiting ? + colors.valid + : (node.isCited ? + colors.warning + : (node.status === PUBLICATION_STATUS_IN_COLLECTION ? + colors.link + : colors.sideSeparator + ) + ) + ) + ) + : colors.active + ) + : colors[`tag${node.tag}`] + )) .on('mouseover', function(node) { - d3.select(this).attr('fill', colors.active); + d3.select(this) + .attr('fill', colors.active) + .attr('stroke', node.tag === null ? colors.active : colors[`tag${node.tag}`]) + ; }) .on('mouseout', function(node) { - d3.select(this).attr('fill', node.selected ? - colors.active - : (node.isCiting ? - colors.valid - : (node.isCited ? - colors.warning - : (node.status === PUBLICATION_STATUS_IN_COLLECTION ? - colors.link - : colors.sideSeparator + d3.select(this) + .attr('fill', node.selected ? + colors.active + : (node.isCiting ? + colors.valid + : (node.isCited ? + colors.warning + : (node.status === PUBLICATION_STATUS_IN_COLLECTION ? + colors.link + : colors.sideSeparator + ) ) ) ) - ); + .attr('stroke', node.tag === null ? + (node.known ? + (node.selected ? + colors.active + : (node.isCiting ? + colors.valid + : (node.isCited ? + colors.warning + : (node.status === PUBLICATION_STATUS_IN_COLLECTION ? + colors.link + : colors.sideSeparator + ) + ) + ) + ) + : colors.active + ) + : colors[`tag${node.tag}`] + ) + ; }) ; this.d3Node.call(d3.drag() @@ -366,10 +409,10 @@ class Graph extends React.Component { if (!d3.event.subject.locked) { d3.event.subject.locked = true; this.d3Node.filter(node => node === d3.event.subject).append('circle') - .attr('r', 23) + .attr('r', 30) .attr('class', 'locked') .attr('fill', 'none') - .attr('stroke-width', 2) + .attr('stroke-width', 4) .attr('stroke', this.props.colors.secondaryContent) ; } diff --git a/source/containers/Information.js b/source/containers/Information.js index 67e4bd9..706f6c7 100644 --- a/source/containers/Information.js +++ b/source/containers/Information.js @@ -15,6 +15,7 @@ import { updatePublication, updateAllPublications, removePublication, + setPublicationTag, } from '../actions/managePublication' import { PUBLICATION_STATUS_UNVALIDATED, @@ -119,6 +120,47 @@ class Information extends React.Component { }} >{this.props.doi} + {this.props.publication.status === PUBLICATION_STATUS_IN_COLLECTION && +
+ {new Array(5).fill().map((_, index) => ( +
{ + this.props.dispatch(setPublicationTag(this.props.doi, this.props.publication.tag === index ? null : index)); + }} + > + + + +
+ ))} +
+ } {this.props.publication.citers.length > 0 &&

{ + if (moveFailed) { + this.props.dispatch(reset({ + ...newState, + warnings: { + ...newState.warnings, + list: [ + ...newState.warnings.list, + { + title: 'The save file was updated', + subtitle: 'Creating a backup failed', + level: 'error', + }, + ], + }, + tabs: 2, + })); + } else { + this.props.dispatch(reset({ + ...newState, + warnings: { + ...newState.warnings, + list: [ + ...newState.warnings.list, + { + title: 'The save file was updated', + subtitle: `A backup was saved to '${backupFilename}'`, + level: 'warning', + }, + ], + }, + })); + } + }); + ipcRenderer.send('backup', openFilename, stateToJson(newState, true)); } else { this.props.dispatch(reset(newState)); } @@ -147,9 +183,45 @@ class Origami extends React.Component { if (failed) { this.props.dispatch(rejectOpen(filename, 'The file could not be open for reading')); } else { - const [error, newState] = jsonToState(data, filename, this.props.state); + const [error, modified, newState] = jsonToState(data, filename, this.props.state); if (error) { - this.props.dispatch(rejectOpen(filename, `Parsing failed: ${error.message}`)); + this.props.dispatch(rejectOpen(filename, error.message)); + } else if (modified) { + ipcRenderer.once('backedup', (event, moveFailed, saveFailed, backupFilename) => { + if (moveFailed) { + this.props.dispatch(reset({ + ...newState, + warnings: { + ...newState.warnings, + list: [ + ...newState.warnings.list, + { + title: 'The save file was updated', + subtitle: 'Creating a backup failed', + level: 'error', + }, + ], + }, + tabs: 2, + })); + } else { + this.props.dispatch(reset({ + ...newState, + warnings: { + ...newState.warnings, + list: [ + ...newState.warnings.list, + { + title: 'The save file was updated', + subtitle: `A backup was saved to '${backupFilename}'`, + level: 'warning', + }, + ], + }, + })); + } + }); + ipcRenderer.send('backup', filename, stateToJson(newState, true)); } else { this.props.dispatch(reset(newState)); } @@ -207,9 +279,9 @@ class Origami extends React.Component { if (failed) { this.props.dispatch(rejectImportPublications(filename, 'The file could not be open for reading')); } else { - const [error, importedState] = jsonToState(data, filename, this.props.state); + const [error, updated, importedState] = jsonToState(data, filename, this.props.state); if (error) { - this.props.dispatch(rejectImportPublications(filename, `Parsing failed: ${error.message}`)); + this.props.dispatch(rejectImportPublications(filename, error.message)); } else { const fetchingDois = new Set([ ...importedState.crossref.requests.filter( diff --git a/source/containers/PublicationsList.js b/source/containers/PublicationsList.js index 7c5acc3..7dec315 100644 --- a/source/containers/PublicationsList.js +++ b/source/containers/PublicationsList.js @@ -55,7 +55,7 @@ class PublicationsList extends React.Component { paddingTop: '6px', paddingRight: '6px', paddingBottom: '6px', - paddingLeft: element.isKnown ? '6px' : '4px', + paddingLeft: element.isKnown && element.tag === null ? '8px' : '4px', backgroundColor: (element.selected ? this.props.colors.active : (element.isCiting ? @@ -70,7 +70,13 @@ class PublicationsList extends React.Component { ) ), borderBottom: `1px solid ${this.props.colors.sideSeparator}`, - borderLeft: element.isKnown ? 'none': `2px solid ${this.props.colors.active}`, + borderLeft: (element.isKnown ? + (element.tag === null ? + 'none' + : `4px solid ${this.props.colors[`tag${element.tag}`]}` + ) + : `4px solid ${this.props.colors.active}` + ), cursor: 'pointer', ':hover': { backgroundColor: this.props.colors.active, diff --git a/source/main.js b/source/main.js index 8f61376..d086a09 100644 --- a/source/main.js +++ b/source/main.js @@ -29,6 +29,11 @@ function createWindow() { 'error', 'valid', 'placeholder', + 'tag0', + 'tag1', + 'tag2', + 'tag3', + 'tag4', ]) { if (!(key in parsedColors) || !(/^#[a-zA-Z0-9]{6}$/.test(parsedColors[key]))) { electron.dialog.showErrorBox('Configuration error', `'${key}' is not a color in '${__dirname}/colors.json'`); @@ -279,7 +284,7 @@ function createWindow() { } else { fs.readFile(filePaths[0], (error, data) => { if (error) { - event.sender.send('opened', false , true, filePaths[0], null); + event.sender.send('opened', false, true, filePaths[0], null); } else { event.sender.send('opened', false, false, filePaths[0], data); } @@ -289,6 +294,41 @@ function createWindow() { ); }); + /// 'backup' moves an existing save and creates a new one, reporting errors with 'backedup'. + /// filename: string, file to backup and replace. If null, the auto-save filename is used. + /// data: string, content to write to the state file + /// 'backedup' arguments: + /// moveFailed: bool, true if moving the original file failed + /// saveFailed: bool, true if creating the new save failed + /// filename: string, the created backup filename + electron.ipcMain.on('backup', (event, filename, data) => { + if (!filename) { + filename = stateFilename; + } + const dateAsString = new Date().toISOString().split('T')[0]; + const pathParts = path.parse(filename); + let backupFilename = `${pathParts.dir}/${pathParts.name}-backup-${dateAsString}${pathParts.ext}`; + for (let index = 1; ; ++index) { + if (!fs.existsSync(backupFilename)) { + break; + } + backupFilename = `${pathParts.dir}/${pathParts.name}-backup-${dateAsString}-${index}${pathParts.ext}`; + } + fs.rename(filename, backupFilename, error => { + if (error) { + event.sender.send('backedup', true, false, backupFilename); + } else { + fs.writeFile(filename, data, error => { + if (error) { + event.sender.send('backedup', false, true, backupFilename); + } else { + event.sender.send('backedup', false, false, backupFilename); + } + }); + } + }); + }); + /// 'import-publications' prompts for a file to open and sends back its contents with 'imported-publications'. /// 'imported-publications' arguments: /// cancelled: bool, true if the opening was cancelled @@ -399,6 +439,7 @@ function createWindow() { } ); }); + mainWindow.once('ready-to-show', () => { mainWindow.show(); }); diff --git a/source/package.json b/source/package.json index 643867d..de89978 100644 --- a/source/package.json +++ b/source/package.json @@ -1,6 +1,6 @@ { "name": "Origami", - "version": "0.8.1", + "version": "0.9.0", "main": "main.js", "private": true, "devDependencies": { diff --git a/source/reducers/menu.js b/source/reducers/menu.js index 03683b5..da2a723 100644 --- a/source/reducers/menu.js +++ b/source/reducers/menu.js @@ -38,7 +38,6 @@ export default function menu(state = { ...state, saveFilename: null, }; - case SELECT_GRAPH_DISPLAY: return { ...state, diff --git a/source/reducers/publications.js b/source/reducers/publications.js index cd1e635..757cd29 100644 --- a/source/reducers/publications.js +++ b/source/reducers/publications.js @@ -8,6 +8,7 @@ import { REMOVE_PUBLICATION, UPDATE_PUBLICATION, UPDATE_ALL_PUBLICATIONS, + SET_PUBLICATION_TAG, RESOLVE_BIBTEX_FROM_DOI, RESOLVE_PUBLICATION_FROM_CITER_METADATA, RESOLVE_PUBLICATION_FROM_IMPORTED_METADATA, @@ -58,6 +59,7 @@ export default function publications(state = new Map(), action, appState) { citers: [], updated: null, selected: false, + tag: null, bibtex: null, x: null, y: null, @@ -149,6 +151,8 @@ export default function publications(state = new Map(), action, appState) { newState.set(action.doi, { ...state.get(action.doi), status: PUBLICATION_STATUS_DEFAULT, + selected: false, + tag: null, bibtex: null, locked: false, citers: [], @@ -230,8 +234,19 @@ export default function publications(state = new Map(), action, appState) { } return newState; } + case SET_PUBLICATION_TAG: { + if (!state.has(action.doi) || state.get(action.doi).status !== PUBLICATION_STATUS_IN_COLLECTION) { + return state; + } + const newState = new Map(state); + newState.set(action.doi, { + ...newState.get(action.doi), + tag: action.tag, + }); + return newState; + } case RESOLVE_BIBTEX_FROM_DOI: { - if (!state.has(action.doi)) { + if (!state.has(action.doi) || state.get(action.doi).status !== PUBLICATION_STATUS_IN_COLLECTION) { return state; } const newState = new Map(state); @@ -295,6 +310,7 @@ export default function publications(state = new Map(), action, appState) { citers: [], updated: null, selected: false, + tag: null, bibtex: null, x: null, y: null, @@ -368,6 +384,7 @@ export default function publications(state = new Map(), action, appState) { citers: [], updated: action.timestamp, selected: false, + tag: null, bibtex: null, x: null, y: null, @@ -437,6 +454,7 @@ export default function publications(state = new Map(), action, appState) { citers: [], updated: null, selected: false, + tag: null, bibtex: null, x: null, y: null, diff --git a/source/state.js b/source/state.js index bdac04d..53d12a7 100644 --- a/source/state.js +++ b/source/state.js @@ -1,7 +1,14 @@ +import Ajv from 'ajv' +import deepEqual from 'deep-equal' import crossrefQueue from './queues/crossrefQueue' import doiQueue from './queues/doiQueue' import scholarQueue from './queues/scholarQueue' import { + PUBLICATION_STATUS_UNVALIDATED, + PUBLICATION_STATUS_DEFAULT, + PUBLICATION_STATUS_IN_COLLECTION, + SCHOLAR_REQUEST_TYPE_INITIALIZE, + SCHOLAR_REQUEST_TYPE_CITERS, SCHOLAR_STATUS_IDLE, SCHOLAR_STATUS_FETCHING, SCHOLAR_STATUS_BLOCKED_HIDDEN, @@ -12,6 +19,416 @@ import { CROSSREF_REQUEST_TYPE_IMPORTED_METADATA, } from './constants/enums' +/// minimumValidate is the minimum schema validator for repairing. +const minimumValidate = new Ajv().compile({ + type: 'object', + properties: { + publications: { + type: 'array', + items: { + type: 'array', + minItems: 2, + maxItems: 2, + items: [ + {type: 'string'}, + {type: 'object'}, + ], + }, + }, + }, + required: ['publications'], +}); + +/// validate is the schema validator for the current version's state. +const validate = new Ajv({removeAdditional: true}).compile({ + type: 'object', + properties: { + appVersion: {type: 'string'}, + display: {type: 'integer', minimum: 0, maximum: 1}, + knownDois: { + type: 'array', + items: {type: 'string'}, + }, + crossref: { + type: 'array', + items: { + type: 'object', + properties: { + type: {type: 'string'}, + doi: {type: 'string'}, + parentDoi: {type: 'string'}, + title: {type: 'string'}, + authors: { + type: 'array', + items: {type: 'string'}, + }, + dateAsString: {type: 'string'}, + }, + additionalProperties: false, + anyOf: [ + { + properties: { + type: {type: 'string', const: CROSSREF_REQUEST_TYPE_VALIDATION}, + doi: {type: 'string'}, + }, + required: ['type', 'doi'], + }, + { + properties: { + type: {type: 'string', const: CROSSREF_REQUEST_TYPE_CITER_METADATA}, + parentDoi: {type: 'string'}, + title: {type: 'string'}, + authors: { + type: 'array', + items: {type: 'string'}, + }, + dateAsString: {type: 'string'}, + }, + required: ['type', 'parentDoi', 'title', 'authors', 'dateAsString'], + }, + { + properties: { + type: {type: 'string', const: CROSSREF_REQUEST_TYPE_IMPORTED_METADATA}, + title: {type: 'string'}, + authors: { + type: 'array', + items: {type: 'string'}, + }, + dateAsString: {type: 'string'}, + }, + required: ['type', 'title', 'authors', 'dateAsString'], + }, + ], + }, + }, + doi: { + type: 'array', + items: { + type: 'object', + properties: { + doi: {type: 'string'}, + }, + additionalProperties: false, + required: ['doi'], + }, + }, + scholar: { + type: 'object', + properties: { + requests: { + type: 'array', + items: { + type: 'object', + properties: { + type: {type: 'string', const: SCHOLAR_REQUEST_TYPE_CITERS}, + doi: {type: 'string'}, + url: {type: 'string'}, + number: {type: 'integer', minimum: 1}, + total: {type: 'integer', minimum: 1}, + }, + additionalProperties: false, + anyOf: [ + { + properties: { + type: {type: 'string', const: SCHOLAR_REQUEST_TYPE_INITIALIZE}, + doi: {type: 'string'}, + url: {type: 'string'}, + }, + required: ['type', 'doi', 'url'], + }, + { + properties: { + type: {type: 'string', const: SCHOLAR_REQUEST_TYPE_CITERS}, + doi: {type: 'string'}, + url: {type: 'string'}, + number: {type: 'integer', minimum: 1}, + total: {type: 'integer', minimum: 1}, + }, + required: ['type', 'doi', 'url', 'number', 'total'], + }, + ], + }, + }, + minimumRefractoryPeriod: {type: 'integer', minimum: 0, maximum: 20000, multipleOf: 100}, + maximumRefractoryPeriod: {type: 'integer', minimum: 0, maximum: 20000, multipleOf: 100}, + }, + additionalProperties: false, + required: ['requests', 'minimumRefractoryPeriod', 'maximumRefractoryPeriod'], + }, + graph: { + type: 'object', + properties: { + threshold: {type: 'integer', minimum: 1}, + zoom: {type: 'integer', minimum: -50, maximum: 50}, + xOffset: {type: 'number'}, + yOffset: {type: 'number'}, + sticky: {type: 'boolean'}, + }, + additionalProperties: false, + required: ['threshold', 'zoom', 'xOffset', 'yOffset', 'sticky'], + }, + publications: { + type: 'array', + items: { + type: 'array', + minItems: 2, + maxItems: 2, + items: [ + {type: 'string'}, + { + properties: { + status: {type: 'string'}, + title: { + anyOf: [ + {type: 'null'}, + {type: 'string'}, + ], + }, + authors: { + anyOf: [ + {type: 'null'}, + { + type: 'array', + items: {type: 'string'}, + }, + ], + }, + journal: { + anyOf: [ + {type: 'null'}, + {type: 'string'}, + ], + }, + date: { + anyOf: [ + {type: 'null'}, + { + type: 'array', + minItems: 1, + maxItems: 3, + items: {type: 'integer'}, + }, + ], + }, + citers: { + type: 'array', + items: {type: 'string'}, + }, + updated: { + anyOf: [ + {type: 'null'}, + {type: 'integer', minimum: 0}, + ], + }, + selected: {type: 'boolean'}, + bibtex: { + anyOf: [ + {type: 'null'}, + {type: 'string'}, + ], + }, + x: { + anyOf: [ + {type: 'null'}, + {type: 'number'}, + ], + }, + y: { + anyOf: [ + {type: 'null'}, + {type: 'number'}, + ], + }, + locked: {type: 'boolean'}, + tag: { + anyOf: [ + {type: 'null'}, + {type: 'integer', minimum: 0, maximum: 5}, + ], + }, + }, + additionalProperties: false, + anyOf: [ + { + type: 'object', + properties: { + status: {type: 'string', const: PUBLICATION_STATUS_UNVALIDATED}, + title: {type: 'null'}, + authors: {type: 'null'}, + journal: {type: 'null'}, + date: {type: 'null'}, + citers: {type: 'array', maxItems: 0}, + updated: {type: 'null'}, + selected: {type: 'boolean', const: false}, + bibtex: {type: 'null'}, + x: {type: 'null'}, + y: {type: 'null'}, + locked: {type: 'boolean', const: false}, + tag: {type: 'null'}, + }, + required: [ + 'status', + 'title', + 'authors', + 'journal', + 'date', + 'citers', + 'updated', + 'selected', + 'bibtex', + 'x', + 'y', + 'locked', + 'tag', + ], + }, + { + type: 'object', + properties: { + status: {type: 'string', const: PUBLICATION_STATUS_DEFAULT}, + title: {type: 'string'}, + authors: { + type: 'array', + items: {type: 'string'}, + }, + journal: {type: 'string'}, + date: { + type: 'array', + minItems: 1, + maxItems: 3, + items: {type: 'integer'}, + }, + citers: {type: 'array', maxItems: 0}, + updated: {type: 'null'}, + selected: {type: 'boolean'}, + bibtex: {type: 'null'}, + x: { + anyOf: [ + {type: 'null'}, + {type: 'number'}, + ] + }, + y: { + anyOf: [ + {type: 'null'}, + {type: 'number'}, + ] + }, + locked: {type: 'boolean', const: false}, + tag: {type: 'null'}, + }, + required: [ + 'status', + 'title', + 'authors', + 'journal', + 'date', + 'citers', + 'updated', + 'selected', + 'bibtex', + 'x', + 'y', + 'locked', + 'tag', + ], + }, + { + type: 'object', + properties: { + status: {type: 'string', const: PUBLICATION_STATUS_IN_COLLECTION}, + title: {type: 'string'}, + authors: { + type: 'array', + items: {type: 'string'}, + }, + journal: {type: 'string'}, + date: { + type: 'array', + minItems: 1, + maxItems: 3, + items: {type: 'integer'}, + }, + citers: { + type: 'array', + items: {type: 'string'}, + }, + updated: {type: 'integer', minimum: 0}, + selected: {type: 'boolean'}, + bibtex: { + anyOf: [ + {type: 'null'}, + {type: 'string'}, + ], + }, + x: {type: 'number'}, + y: {type: 'number'}, + locked: {type: 'boolean'}, + tag: { + anyOf: [ + {type: 'null'}, + {type: 'integer', minimum: 0, maximum: 5}, + ], + }, + }, + required: [ + 'status', + 'title', + 'authors', + 'journal', + 'date', + 'citers', + 'updated', + 'selected', + 'bibtex', + 'x', + 'y', + 'locked', + 'tag', + ], + }, + ], + }, + ], + }, + }, + search: {type: 'string'}, + tabs: {type: 'integer', minimum: 0, maximum: 2}, + warnings: { + type: 'array', + items: { + type: 'object', + properties: { + title: {type: 'string'}, + subtitle: {type: 'string'}, + level: {type: 'string', enum: ['warning', 'error']}, + }, + additionalProperties: false, + required: ['title', 'subtitle', 'level'], + }, + }, + saveFilename: { + anyOf: [ + {type: 'null'}, + {type: 'string'}, + ], + }, + }, + required: [ + 'appVersion', + 'display', + 'knownDois', + 'crossref', + 'doi', + 'scholar', + 'graph', + 'publications', + 'search', + 'tabs', + 'warnings', + ], +}); + /// stateToJson generates a JSON string from an app state. /// expand must be a boolean: /// if true, a user-save JSON is generated (pretty-printed) @@ -41,61 +458,186 @@ export function stateToJson(state, expand) { }, null, expand ? ' ' : null)}${expand ? '\n' : ''}`; } +/// merge fills non-saved properties. +/// saveFilename (string): if null (auto-save JSON), the JSON's saveFilename is used +/// previousState (object): if null (auto-save JSON), some state parameters are left empty (and must be added manually) +function merge(state, saveFilename, previousState) { + + // fill properties + state.appVersion = previousState ? previousState.appVersion : undefined; + state.colors = previousState ? previousState.colors : undefined; + state.connected = previousState ? previousState.connected : undefined; + state.crossref = { + status: crossrefQueue.status.IDLE, + requests: state.crossref, + }; + state.doi = { + status: doiQueue.status.IDLE, + requests: state.doi, + }; + state.knownDois = new Set(state.knownDois); + state.menu = { + activeItem: null, + hash: previousState ? previousState.menu.hash + 1 : 0, + saveFilename: saveFilename ? saveFilename : state.saveFilename, + savedVersion: previousState ? previousState.version + 1 : 0, + display: state.display, + }; + delete state.saveFilename; + delete state.display; + state.publications = new Map(state.publications); + state.scholar.status = SCHOLAR_STATUS_IDLE; + state.scholar.beginOfRefractoryPeriod = null; + state.scholar.endOfRefractoryPeriod = null; + state.scholar.url = null; + state.tabs = { + index: state.tabs, + hash: previousState ? previousState.tabs.hash + 1 : 0, + }; + state.version = (previousState ? previousState.version + 1 : (state.savable ? 1 : 0)); + delete state.savable; + state.warnings = { + list: state.warnings, + hash: previousState ? previousState.warnings.hash + 1 : 0, + }; + + // verify integrity + for (const [doi, publication] of state.publications.entries()) { + publication.citers = publication.citers.filter(citer => state.publications.has(citer)); + } + state.crossref.requests = state.crossref.requests.filter(request => ( + (request.type === CROSSREF_REQUEST_TYPE_VALIDATION && state.publications.has(request.doi)) + || (request.type === CROSSREF_REQUEST_TYPE_CITER_METADATA && state.publications.has(request.parentDoi)) + || request.type === CROSSREF_REQUEST_TYPE_IMPORTED_METADATA + )); + state.doi.requests = state.doi.requests.filter(request => state.publications.has(request.doi)); + state.scholar.requests = state.scholar.requests.filter(request => state.publications.has(request.doi)); + state.warnings.list = state.warnings.list.filter(warning => warning.title !== ''); + let selectedFound = false; + for (const publication of state.publications.values()) { + if (publication.selected) { + if (selectedFound) { + publication.selected = false; + } else { + selectedFound = true; + } + } + } + const citersCountByDoi = new Map(); + for (const [doi, publication] of state.publications.entries()) { + if (publication.status === PUBLICATION_STATUS_IN_COLLECTION) { + for (const citer of publication.citers) { + if (state.publications.get(citer).status === PUBLICATION_STATUS_DEFAULT) { + if (citersCountByDoi.has(citer)) { + citersCountByDoi.set(citer, citersCountByDoi.get(citer) + 1); + } else { + citersCountByDoi.set(citer, 1); + } + } + } + } + } + state.graph.threshold = Math.min(state.graph.threshold, citersCountByDoi.size > 0 ? Math.max(...citersCountByDoi.values()) + 1 : 2); + if (state.scholar.minimumRefractoryPeriod > state.scholar.maximumRefractoryPeriod) { + state.scholar.minimumRefractoryPeriod = state.scholar.maximumRefractoryPeriod; + } + + return state; +} + /// jsonToState generates an app state from a JSON buffer. -/// saveFilename must be a string: -/// if null (typically, for an auto-save JSON), the JSON's saveFilename is used -/// previousState must be an object: -/// if null (typically, for an auto-save JSON), some state parameters are left empty (and must be added manually) +/// saveFilename (string): if null (auto-save JSON), the JSON's saveFilename is used +/// previousState (object): if null (auto-save JSON), some state parameters are left empty (and must be added manually) +/// returned value ([error, modified, state]): +/// error (object): if not null, no state is generated +/// modified (boolean): if true, non-reversible modifications were requried +/// state (boolean): the parsed state export function jsonToState(json, saveFilename, previousState) { + const jsonAsString = new TextDecoder('utf-8').decode(json); + let stateCandidate; try { - const state = JSON.parse(new TextDecoder('utf-8').decode(json)); - state.appVersion = previousState ? previousState.appVersion : undefined; - state.colors = previousState ? previousState.colors : undefined; - state.connected = previousState ? previousState.connected : undefined; - state.crossref = { - status: crossrefQueue.status.IDLE, - requests: state.crossref, - }; - state.doi = { - status: doiQueue.status.IDLE, - requests: state.doi, - }; - state.knownDois = new Set(state.knownDois); - state.menu = { - activeItem: null, - hash: previousState ? previousState.menu.hash + 1 : 0, - saveFilename: saveFilename ? saveFilename : state.saveFilename, - savedVersion: previousState ? previousState.version + 1 : 0, - display: state.display, - }; - delete state.saveFilename; - delete state.display; - state.publications = new Map(state.publications); - state.scholar.status = SCHOLAR_STATUS_IDLE; - state.scholar.beginOfRefractoryPeriod = null; - state.scholar.endOfRefractoryPeriod = null; - state.scholar.url = null; - state.tabs = { - index: state.tabs, - hash: previousState ? previousState.tabs.hash + 1 : 0, - }; - state.version = (previousState ? - previousState.version + 1 - : (state.savable ? - 1 - : 0 - ) - ); - delete state.savable; - state.warnings = { - list: state.warnings, - hash: previousState ? previousState.warnings.hash + 1 : 0, - }; - return [null, state]; - } catch(error) { - return [error, null]; + stateCandidate = JSON.parse(jsonAsString); + } catch (error) { + return [new Error(`Parsing failed: ${error.message}`), false, null]; } -} + if (!minimumValidate(stateCandidate)) { + return [new Error('The JSON file does not have the expected structure'), false, null]; + } + if (validate(stateCandidate)) { + return [null, !deepEqual(stateCandidate, JSON.parse(jsonAsString)), merge(stateCandidate, saveFilename, previousState)]; + } + for (;;) { + let errors = validate.errors; + console.error(errors.map(error => error.keyword).join(', '), errors, errors.map(error => eval(`stateCandidate${error.dataPath}`))); + if (errors.length > 1 && errors[errors.length - 1].keyword === 'anyOf') { + errors = errors.slice(0, errors.length - 1).filter(error => error.keyword !== 'const'); + if (errors.length === 0) { + const match = /(^[\w\.]+)\[(\d+)\]/.exec(validate.errors[validate.errors.length - 1].dataPath); + if (!match) { + return [new Error('The data path for an \'anyOf\' constraint does not have the expected format'), false, null]; + } + eval(`stateCandidate${match[1]}.splice(${match[2]}, 1);`); + continue; + } + } + for (const error of errors) { + switch (error.keyword) { + case 'required': { + eval(`stateCandidate${error.dataPath}.${error.params.missingProperty} = null;`); + break; + } + case 'type': { + const types = ['null', 'boolean', 'integer', 'number', 'string', 'array', 'object'].filter( + type => error.params.type.split(',').includes(type) + ); + if (types.length === 0) { + return [new Error(`Unknown type '${error.params.type}'`), false, null]; + } + switch (types[0]) { + case 'null': + eval(`stateCandidate${error.dataPath} = null;`); + break; + case 'boolean': + eval(`stateCandidate${error.dataPath} = false;`); + break; + case 'integer': + eval(`stateCandidate${error.dataPath} = 0;`); + break; + case 'number': + eval(`stateCandidate${error.dataPath} = 0;`); + break; + case 'string': + eval(`stateCandidate${error.dataPath} = '';`); + break; + case 'array': + eval(`stateCandidate${error.dataPath} = [];`); + break; + case 'object': + eval(`stateCandidate${error.dataPath} = {};`); + break; + default: + return [new Error(`Unknown type '${types[0]}'`), false, null]; + } + break; + } + case 'maximum': + case 'minimum': { + eval(`stateCandidate${error.dataPath} = ${error.params.limit};`); + break; + } + case 'multipleOf': { + eval(`stateCandidate${error.dataPath} = Math.round(stateCandidate${error.dataPath} / ${error.params.multipleOf}) * ${error.params.multipleOf};`); + break; + } + default: + return [new Error(`Unknown error keyword '${error.keyword}'`), false, null]; + } + } + if (validate(stateCandidate)) { + return [null, true, merge(stateCandidate, saveFilename, previousState)]; + } + } +}; /// resetState generates a reset state with incremented hashes. /// state must be an app state. diff --git a/themes/default.json b/themes/default.json index 89dd35f..0c1b13e 100644 --- a/themes/default.json +++ b/themes/default.json @@ -9,5 +9,10 @@ "warning": "#f4c20d", "error": "#db4733", "valid": "#3cba54", - "placeholder": "#a9a9a9" + "placeholder": "#a9a9a9", + "tag0": "#008cff", + "tag1": "#f4c20d", + "tag2": "#db4733", + "tag3": "#3cba54", + "tag4": "#d88fe2" } diff --git a/themes/solarized.json b/themes/solarized.json index f5e87dd..79b81f4 100644 --- a/themes/solarized.json +++ b/themes/solarized.json @@ -9,5 +9,10 @@ "warning": "#b58900", "error": "#dc322f", "valid": "#859900", - "placeholder": "#93a1a1" + "placeholder": "#93a1a1", + "tag0": "#268bd2", + "tag1": "#b58900", + "tag2": "#dc322f", + "tag3": "#859900", + "tag4": "#d33682" } diff --git a/webpack.common.js b/webpack.common.js deleted file mode 100644 index a856c1b..0000000 --- a/webpack.common.js +++ /dev/null @@ -1,77 +0,0 @@ -const fs = require('fs'); -const webpack = require('webpack'); -const child_process = require('child_process'); -const packager = require('electron-packager'); -const recursive = require(`${__dirname}/recursive`); - -module.exports = { - - /// package uses electron-packager to convert the output of webpack to actual apps. - package: (production, callback) => { - try { - fs.mkdirSync(`${__dirname}/build/origami`); - } catch (error) {} - fs.copyFileSync(`${__dirname}/source/package.json`, `${__dirname}/build/origami/package.json`); - fs.writeFileSync(`${__dirname}/build/origami/main.js`, `process.env.ORIGAMI_ENV = '${production ? 'production' : 'development'}';\n`); - fs.appendFileSync(`${__dirname}/build/origami/main.js`, fs.readFileSync(`${__dirname}/source/main.js`)); - fs.copyFileSync(`${__dirname}/themes/default.json`, `${__dirname}/build/origami/colors.json`); - try { - fs.mkdirSync(`${__dirname}/build/origami/fonts`); - } catch (error) {} - recursive.copyFileSync(`${__dirname}/fonts`, `${__dirname}/build/origami/fonts`); - fs.copyFileSync(`${__dirname}/icons/origami.png`, `${__dirname}/build/origami/origami.png`); - fs.copyFileSync(`${__dirname}/build/index.html`, `${__dirname}/build/origami/index.html`); - child_process.execSync('npm install', {cwd: `${__dirname}/build/origami`}, {stdio: 'inherit'}); - packager({ - dir: `${__dirname}/build/origami`, - all: production, - out: `${__dirname}/build`, - overwrite: true, - icon: `${__dirname}/icons/origami`, - download: { - cache: `${__dirname}/build/cache`, - }, - }, (error, appPaths) => { - if (error) { - console.error(error); - } else { - console.log(appPaths.map(appPath => `Created ${appPath}`).join('\n')); - } - if (callback) { - callback(); - } - }); - }, - - /// configuration defines common webpack parameters for dev and prod. - configuration: { - target: 'electron-renderer', - entry: './source/app.js', - output: { - path: `${__dirname}/build`, - filename: 'index.js', - }, - module: { - loaders: [ - { - test: /\.js$/, - loader: 'babel-loader', - exclude: /node_modules/, - query: { - presets: [ - ['env', {'targets': {'electron': '1.7.6'}}], - 'react', - ], - plugins: [ - 'babel-plugin-transform-class-properties', - 'babel-plugin-transform-object-rest-spread', - ], - }, - }, - ], - }, - resolve: { - modules: ['node_modules'], - }, - }, -};