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'],
- },
- },
-};