Compare commits

..

No commits in common. "master" and "version-2" have entirely different histories.

31 changed files with 638 additions and 656 deletions

1
.gitattributes vendored
View File

@ -1 +0,0 @@
* text=auto

22
LICENSE
View File

@ -1,22 +0,0 @@
The MIT License (MIT)
Copyright (c) 2015 Adam Brown
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -39,10 +39,10 @@ To build and launch a dev server:
npm start
npm run server
To build and launch the production server:
To build and launch the distribution server:
npm run build:prod
npm run serve:prod
npm run build:dist
npm run server:dist
@ -62,13 +62,8 @@ On a low-memory machine, eg. a DigitalOcean 512MB instance, you will need to ena
To make the application start on boot, run the following:
# initialize pm2 to start on boot with the systemd boot manager
pm2 start grunt --name dubdiff -- serve:dist
pm2 startup systemd
# start the app with pm2
pm2 start npm --name dubdiff -- run serve:prod
# save the current pm2 config so that it can be reloaded on boot
pm2 save
[Digital Ocean: How To Set Up a Node.js Application for Production on Ubuntu 16.04](https://www.digitalocean.com/community/tutorials/how-to-set-up-a-node-js-application-for-production-on-ubuntu-16-04)

34
TODO.md
View File

@ -1 +1,33 @@
Support for displaying and responding to `#markdown` path suffix.
- create production mode build and serve settings: `webpack.js` and `src/server/babel.index.js`
## State changes
main page edits input documents
compare page views compare documents
compare button:
- generate id
- post input documents to server
- input documents copied to compare documents
- input documents cleared
- go to compare route
edit button:
- compare documents copied to input documents
- compare documents cleared
- go to main route
client start:
- load input documents from localStore
server start:
- load compare documents from database
* client actually never needs to query server for compare documents... huh!

8
dist/main.css vendored
View File

@ -1,11 +1,3 @@
#masthead .header {
font-size: 4em;
}
ins {
background-color: #dbffdb;
}
del {
background-color: #ffdddd;
}

View File

@ -1,19 +1,16 @@
{
"name": "dubdiff",
"version": "2.0.1",
"name": "dubdiff-2",
"version": "0.0.0",
"description": "",
"main": "src/server/babel.index.js",
"scripts": {
"copy-css": "cpy --parents --cwd=./node_modules/semantic-ui-css semantic.min.css themes/default/assets/fonts/icons.woff2 ../../dist",
"build": "npm run copy-css && webpack --progress --colors",
"build:prod": "npm run copy-css && cross-env NODE_ENV=production webpack -p --progress --colors",
"build:prod:nocopy": "cross-env NODE_ENV=production webpack -p --progress --colors",
"build:watch": "npm run copy-css && webpack --progress --colors --watch",
"serve": "node src/server/babel.index.js",
"serve:prod": "cross-env NODE_ENV=production node src/server/babel.index.js",
"webpack-stats": "webpack --json > stats.json",
"lint": "standard --verbose | snazzy",
"lint:fix": "standard --fix --verbose | snazzy",
"test": "mocha --watch --compilers js:babel-register"
},
"author": "",
@ -52,13 +49,10 @@
"babel-register": "^6.18.0",
"chai": "^3.5.0",
"copyfiles": "^0.2.2",
"cpy-cli": "^1.0.1",
"cross-env": "^3.1.3",
"json-loader": "^0.5.4",
"mocha": "^3.2.0",
"piping": "^1.0.0-rc.4",
"snazzy": "^6.0.0",
"standard": "^8.6.0",
"webpack": "^2.1.0-beta.27"
}
}

View File

@ -1,71 +1,75 @@
import React from 'react'
import {connect} from 'react-redux'
import * as Actions from '../common/actions'
import * as Selectors from '../common/selectors'
/* This component reads the local storage store and adds them to the Redux store.
* Local storage is read during the componentDidMount lifecycle method.
* Local storage is written during the componentWillReceiveProps lifecycle method.
*/
// an app-specific name for the localStorage state
const stateName = 'dubdiff_state'
//an app-specific name for the localStorage state
const stateName = "dubdiff_state"
// return a new object with the given keys, each assigned to the cooresponding value
// from the given object
const copyKeys = (obj, keys) => keys.reduce((acc, p) => { acc[p] = obj[p]; return acc }, {})
//return a new object with the given keys, each assigned to the cooresponding value
//from the given object
const copyKeys = (obj, keys) => keys.reduce((acc, p)=>{acc[p]=obj[p]; return acc}, {})
// utility method for retrieving json data from the local store
/*
//utility method for retrieving json data from the local store
function getLocalState (keys) {
if (window.localStorage.getItem(stateName)) {
const localState = JSON.parse(window.localStorage.getItem(stateName))
if (localStorage.getItem(stateName)) {
const localState = JSON.parse(localStorage.getItem(stateName))
return copyKeys(localState, keys)
} else {
return copyKeys({}, keys)
}
}
*/
else
return copyKeys({}, keys)
}
// utility method for writing json data to the local store
//utility method for writing json data to the local store
function setLocalState (state, keys) {
let toSave = copyKeys(state, keys)
window.localStorage.setItem(stateName, JSON.stringify(toSave))
localStorage.setItem(stateName, JSON.stringify(toSave))
}
const mapStateToProps = (state) => ({
input: state.input
// the loading/empty/clean state
input: state.input,
//the loading/empty/clean state
})
const mapDispatchToProps = dispatch => ({
onChangeOriginal: (text) => dispatch(Actions.updateOriginalInput(text)),
onChangeFinal: (text) => dispatch(Actions.updateFinalInput(text))
onChangeFinal: (text) => dispatch(Actions.updateFinalInput(text)),
})
class LocalStorage extends React.Component {
// load the state from the local storage
componentDidMount () {
// only if the status is EMPTY
class LocalStorage extends React.Component {
//load the state from the local storage
componentDidMount() {
//only if the status is EMPTY
/*
if (this.props.input.original=='' && this.props.input.final == '') {
const localState = getLocalState(['input'])
if (localState.input && localState.input.original)
this.props.onChangeOriginal(localState.input.original)
if (localState.input && localState.input.final)
if (localState.input && localState.input.final)
this.props.onChangeFinal(localState.input.final)
}
*/
}
// save the state to local storage
componentWillReceiveProps (nextProps) {
//save the state to local storage
componentWillReceiveProps(nextProps) {
setLocalState(nextProps, ['input'])
}
render () {
render () {
return this.props.children
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(LocalStorage)

View File

@ -5,25 +5,26 @@ import * as Redux from 'redux'
import {Provider} from 'react-redux'
// import createBrowserHistory from 'history/lib/createBrowserHistory'
import {Router, browserHistory} from 'react-router'
//import createBrowserHistory from 'history/lib/createBrowserHistory'
import {Router, Route, IndexRoute, Redirect, browserHistory } from 'react-router'
import thunk from 'redux-thunk'
import * as reducers from '../common/reducers'
import routes from '../common/routes'
import {Format} from '../common/constants'
import * as Actions from '../common/actions'
import * as Actions from '../common/actions'
import LocalStorage from './LocalStorage'
// initial state is rehydrated from the server
const initialState = JSON.parse(decodeURI(window.__INITIAL_STATE__))
// create the redux store
// initial state is retrieved from localStore
//initial state is rehydrated from the server
const initialState = window.__INITIAL_STATE__
//create the redux store
//initial state is retrieved from localStore
const store = Redux.createStore(
Redux.combineReducers(reducers),
Redux.combineReducers(reducers),
initialState,
Redux.compose(
Redux.applyMiddleware(thunk),
@ -31,58 +32,19 @@ const store = Redux.createStore(
)
)
render()
// read and init the hash after render:
// because this parameter is passed as a hash, it isn't available on the server,
// so the server initial render will be with plaintext, so we have to start with that.
// lame.
initFormatHash(store)
registerFormatListener(store)
// detect hash parameter for markdown/plaintext format and initialize store
function initFormatHash(store) {
// get the has from the window location
let hash = window.location.hash.toUpperCase()
// strip the hash sign
hash = hash.substring(1)
// dispatch the appropriate action
if (hash === Format.MARKDOWN)
store.dispatch(Actions.setMarkdownFormat())
else if (hash === Format.PLAINTEXT || hash === '')
store.dispatch(Actions.setPlaintextFormat())
}
// listen to changes in the redux store and update the url hash parameter when appropriate
function registerFormatListener(store) {
function handleChange() {
let nextFormat = store.getState().format;
if (nextFormat === Format.MARKDOWN)
window.history.replaceState("", document.title, window.location.pathname+"#"+nextFormat.toLowerCase());
else if (nextFormat === Format.PLAINTEXT) {
window.history.replaceState("", document.title, window.location.pathname);
}
}
let unsubscribe = store.subscribe(handleChange);
return unsubscribe;
}
function render () {
ReactDOM.render(
function render() {
ReactDOM.render(
<Provider store={store}>
<LocalStorage >
<Router history={browserHistory}>
{routes}
{routes}
</Router>
</LocalStorage>
</Provider>
, document.getElementById('root'))
}
render()

View File

@ -3,81 +3,84 @@ import uuid from 'uuid/v4'
import {browserHistory} from 'react-router'
import {Status, StatusError} from './constants'
// All state transitions in the app happen in these methods
// this includes redux state changes, asyncronous data requests, and browser location changes
//All state transitions in the app happen in these methods
//this includes redux state changes, asyncronous data requests, and browser location changes
export const updateOriginalInput = (text) =>
export const updateOriginalInput = (text) =>
(dispatch, getState) => {
dispatch({type: 'UPDATE_ORIGINAL_INPUT', data: text})
if (getState().input.original.length > 0) {
dispatch({type: 'STATUS_SET', data: Status.DIRTY})
} else {
dispatch({type: 'STATUS_SET', data: Status.EMPTY})
}
dispatch({type: 'UPDATE_ORIGINAL_INPUT', data:text})
if (getState().input.original.length>0)
dispatch({type: 'STATUS_SET', data:Status.DIRTY})
else
dispatch({type: 'STATUS_SET', data:Status.EMPTY})
}
export const updateFinalInput = (text) =>
export const updateFinalInput = (text) =>
(dispatch, getState) => {
dispatch({type: 'UPDATE_FINAL_INPUT', data: text})
if (getState().input.final.length > 0) {
dispatch({type: 'STATUS_SET', data: Status.DIRTY})
} else {
dispatch({type: 'STATUS_SET', data: Status.EMPTY})
}
dispatch({ type: 'UPDATE_FINAL_INPUT', data:text})
if (getState().input.final.length>0)
dispatch({type: 'STATUS_SET', data:Status.DIRTY})
else
dispatch({type: 'STATUS_SET', data:Status.EMPTY})
}
export const clearInput = () =>
(dispatch) => {
dispatch({type: 'CLEAR_INPUT'})
dispatch({type: 'STATUS_SET', data: Status.EMPTY})
dispatch({type: 'STATUS_SET', data:Status.EMPTY})
}
export const setPlaintextFormat = () => ({ type: 'SET_PLAINTEXT_FORMAT' })
export const setMarkdownFormat = () => ({ type: 'SET_MARKDOWN_FORMAT' })
export const showOriginal = () => ({ type: 'SHOW_ORIGINAL' })
export const showFinal = () => ({ type: 'SHOW_FINAL' })
export const showDifference = () => ({ type: 'SHOW_DIFFERENCE' })
export const setPlaintextFormat = () => ({ type: 'SET_PLAINTEXT_FORMAT'})
export const setMarkdownFormat = () => ({ type: 'SET_MARKDOWN_FORMAT'})
export const showOriginal = () => ({ type: 'SHOW_ORIGINAL'})
export const showFinal = () => ({ type: 'SHOW_FINAL'})
export const showDifference = () => ({ type: 'SHOW_DIFFERENCE'})
// if the input is dirty, saves it to the server
// creates a new uuid for the same,
// then changes the browser location to a comparison view with that id
export const compare = () =>
//if the input is dirty, saves it to the server
//creates a new uuid for the same,
//then changes the browser location to a comparison view with that id
export const compare = () =>
(dispatch, getState) => {
//! !! could test that the input is dirty before triggering a save
// if the input is empty, the compare should do nothing
// if the input is clean, the compare should not save and keep using the same id
//!!! could test that the input is dirty before triggering a save
//if the input is empty, the compare should do nothing
//if the input is clean, the compare should not save and keep using the same id
// start saving the input to the server
//start saving the input to the server
const id = dispatch(save())
// we can use the id created by the save method to build a path
//we can use the id created by the save method to build a path
const comparePath = `/${id}`
browserHistory.replace(comparePath)
}
// clear the input and return to the edit page
export const reset = () =>
//clear the input and return to the edit page
export const reset = () =>
(dispatch, getState) => {
dispatch(clearInput())
browserHistory.push('/')
}
// switch to the edit view
export const edit = () =>
//switch to the edit view
export const edit = () =>
(dispatch, getState) => {
browserHistory.push('/')
}
// saves the current input fields to the server
// creates and returns a new id for the comparison
// should this method ensure that the initial state is valid? ('DIRTY')
//saves the current input fields to the server
//creates and returns a new id for the comparison
//should this method ensure that the initial state is valid? ('DIRTY')
export const save = () =>
(dispatch, getState) => {
// generate an id
//generate an id
const id = uuid()
// set waiting state
dispatch({type: 'STATUS_SET', data: Status.SAVING})
//set waiting state
dispatch( {type: 'STATUS_SET', data:Status.SAVING})
const endpointUri = `/api/compare/${id}`
const fetchOptions = {
@ -87,31 +90,32 @@ export const save = () =>
b: getState().input.final
}),
headers: {
'Content-Type': 'application/json'
}
"Content-Type": "application/json"
},
}
// dispatch post request
//dispatch post request
fetch(endpointUri, fetchOptions)
.then(response => {
if (response.ok) {
if (response.ok)
dispatch({type: 'STATUS_SET', data: Status.CLEAN})
} else {
response.text().then((responseText) => {
const error = {message: `${response.status}: ${responseText}`}
else {
response.text().then( (responseText) => {
const error = {message:`${response.status}: ${responseText}`}
dispatch({type: 'STATUS_SET', data: Status.DIRTY})
dispatch({type: 'STATUS_SET_ERROR', data: StatusError.SAVE_ERROR, error})
})
}
})
.catch(error => {
//! !! could use a better error message here
//!!! could use a better error message here
dispatch({type: 'STATUS_SET', data: Status.DIRTY})
dispatch({type: 'STATUS_SET_ERROR', data: StatusError.SAVE_ERROR, error})
})
// return the id after the request has been sent
return id
//return the id after the request has been sent
return id;
}
/*
@ -126,6 +130,7 @@ const load = (id) =>
method: 'GET'
}
//dispatch post request
fetch(endpointUri, fetchOptions)
.then(response => response.json())
@ -147,4 +152,4 @@ export const loadIfNeeded = (id) =>
(dispatch, getState) => {
if
}
*/
*/

View File

@ -1,12 +1,11 @@
import React from 'react'
import {connect} from 'react-redux'
import {Segment, Grid} from 'semantic-ui-react'
import {Segment, Grid, Form} from 'semantic-ui-react'
import * as Actions from '../actions'
import * as Selectors from '../selectors'
import {Format} from '../constants'
import Header from './Header'
import Footer from './Footer'
import CompareControls from './CompareControls'
@ -16,17 +15,19 @@ import ShowMarkdown from './ShowMarkdown'
const mapStateToProps = (state) => ({
isMarkdownFormat: Selectors.isMarkdownFormat(state),
isShowOriginal: Selectors.isShowOriginal(state),
isShowOriginal: Selectors.isShowOriginal(state),
isShowFinal: Selectors.isShowFinal(state),
isShowDifference: Selectors.isShowDifference(state),
safeInput: Selectors.safeInput(state),
safeInput: Selectors.safeInput(state),
diff: Selectors.diff(state)
})
const mapDispatchToProps = dispatch => ({
// loadIfNeeded: (id) => dispatch(Actions.loadIfNeeded())
//loadIfNeeded: (id) => dispatch(Actions.loadIfNeeded())
})
class Compare extends React.Component {
/*
componentDidMount() {
@ -34,39 +35,39 @@ class Compare extends React.Component {
}
*/
render () {
render() {
return (
<div>
<Header />
<Header/>
<Segment basic padded>
<Grid stackable columns={2}>
<Grid.Column width='3'>
<CompareControls />
<Grid.Column width="3">
<CompareControls/>
</Grid.Column>
<Grid.Column width='13'>
<Grid.Column width="13">
<Segment>
{
(!this.props.isMarkdownFormat && this.props.isShowDifference)
? <ShowPlaintext diff={this.props.diff}>{this.props.diff}</ShowPlaintext>
: (this.props.isMarkdownFormat && this.props.isShowDifference)
? <ShowMarkdown diff={this.props.diff}>{this.props.diff}</ShowMarkdown>
: (!this.props.isMarkdownFormat && !this.props.isShowDifference)
? <ShowPlaintext
text={this.props.isShowOriginal ? this.props.safeInput.original : this.props.safeInput.final}
/>
: (this.props.isMarkdownFormat && !this.props.isShowDifference)
? <ShowMarkdown
text={this.props.isShowOriginal ? this.props.safeInput.original : this.props.safeInput.final}
/>
: null
(!this.props.isMarkdownFormat && this.props.isShowDifference) ?
<ShowPlaintext diff={this.props.diff}>{this.props.diff}</ShowPlaintext>:
(this.props.isMarkdownFormat && this.props.isShowDifference) ?
<ShowMarkdown diff={this.props.diff}>{this.props.diff}</ShowMarkdown>:
(!this.props.isMarkdownFormat && !this.props.isShowDifference) ?
<ShowPlaintext
text={this.props.isShowOriginal? this.props.safeInput.original: this.props.safeInput.final}
/> :
(this.props.isMarkdownFormat && !this.props.isShowDifference) ?
<ShowMarkdown
text={this.props.isShowOriginal? this.props.safeInput.original: this.props.safeInput.final}
/> :
null
}
</Segment>
</Grid.Column>
</Grid>
</Segment>
<Footer />
<Footer/>
</div>
)
}
@ -74,9 +75,10 @@ class Compare extends React.Component {
export default connect(mapStateToProps, mapDispatchToProps)(Compare)
/* <div ng-if="isMarkdownFormat">
<div ng-show="isShowBefore" class="col-md-10 col-sm-12 content-well">
<div btf-markdown="before" class="before">
<div btf-markdown="before" class="before">
</div>
</div>
<div ng-show="isShowWdiff" class="col-md-10 col-sm-12 content-well">
@ -99,4 +101,4 @@ export default connect(mapStateToProps, mapDispatchToProps)(Compare)
<div ng-bind-html="after" class="content-pre after"></div>
</div>
</div>
*/
*/

View File

@ -1,5 +1,6 @@
import React from 'react'
import {connect} from 'react-redux'
import {Link} from 'react-router'
import {Button, Icon, Segment} from 'semantic-ui-react'
@ -10,12 +11,13 @@ const mapStateToProps = (state) => ({
isMarkdownFormat: Selectors.isMarkdownFormat(state),
isShowOriginal: Selectors.isShowOriginal(state),
isShowFinal: Selectors.isShowFinal(state),
isShowDifference: Selectors.isShowDifference(state)
isShowDifference: Selectors.isShowDifference(state),
})
const mapDispatchToProps = dispatch => ({
onSetPlaintextFormat: () => dispatch(Actions.setPlaintextFormat()),
onSetMarkdownFormat: () => dispatch(Actions.setMarkdownFormat()),
onSetMarkdownFormat: () => dispatch(Actions.setMarkdownFormat()),
onShowOriginal: () => dispatch(Actions.showOriginal()),
onShowFinal: () => dispatch(Actions.showFinal()),
onShowDifference: () => dispatch(Actions.showDifference()),
@ -24,19 +26,19 @@ const mapDispatchToProps = dispatch => ({
class CompareControls extends React.Component {
onClickMarkdownFormat () {
if (this.props.isMarkdownFormat) {
onClickMarkdownFormat() {
if (this.props.isMarkdownFormat)
this.props.onSetPlaintextFormat()
} else {
else
this.props.onSetMarkdownFormat()
}
}
render () {
render() {
return (
<Segment.Group>
<Segment>
<Button fluid onClick={this.props.onEdit}>Edit</Button>
<Button fluid onClick={this.props.onEdit}>Edit</Button>
</Segment>
<Segment >
@ -46,8 +48,8 @@ class CompareControls extends React.Component {
</Segment>
<Segment >
<Button fluid active={this.props.isMarkdownFormat} type='submit' onClick={this.onClickMarkdownFormat.bind(this)}>
{this.props.isMarkdownFormat ? <Icon name='checkmark' /> : <span />}
<Button fluid active={this.props.isMarkdownFormat} type="submit" onClick={this.onClickMarkdownFormat.bind(this)}>
{this.props.isMarkdownFormat ? <Icon name="checkmark"/> : <span/>}
&nbsp;As Markdown
</Button>
</Segment>

View File

@ -3,9 +3,9 @@ import React from 'react'
import {Segment} from 'semantic-ui-react'
const Footer = (props) => (
<Segment basic padded textAlign='center' as='footer'>
<p><a href='http://adamarthurryan.com'>Adam Brown</a> | This website is <a href='https://github.com/adamarthurryan/dubdiff'>open source</a>.</p>
<Segment basic padded textAlign="center" as="footer">
<p><a href="http://adamarthurryan.com">Adam Brown</a> | This website is <a href="https://github.com/adamarthurryan/dubdiff">open source</a>.</p>
</Segment>
)
export default Footer
export default Footer

View File

@ -1,7 +1,7 @@
import React from 'react'
import {connect} from 'react-redux'
import {Segment, Header, Rail} from 'semantic-ui-react'
import {Segment, Header, Rail, Container} from 'semantic-ui-react'
import {Link} from 'react-router'
import * as Actions from '../actions'
@ -10,25 +10,27 @@ import SaveStatus from './SaveStatus'
const mapStateToProps = (state) => ({
})
const mapDispatchToProps = dispatch => ({
onReset: () => { console.log(Actions.reset()); dispatch(Actions.reset()) }
onReset: () => { console.log(Actions.reset()); dispatch(Actions.reset())},
})
const SiteHeader = (props) => (
<Segment basic >
<Segment basic >
<Segment basic padded textAlign='center' as='header' id='masthead'>
<Header><Link onClick={props.onReset}>dubdiff</Link></Header>
</Segment>
<Rail internal position='right'>
<Segment basic padded>
<SaveStatus />
<Segment basic padded textAlign="center" as="header" id='masthead'>
<Header><Link onClick={props.onReset}>dubdiff</Link></Header>
</Segment>
</Rail>
</Segment>
<Rail internal position="right">
<Segment basic padded>
<SaveStatus/>
</Segment>
</Rail>
</Segment>
)
export default connect(mapStateToProps, mapDispatchToProps)(SiteHeader)

View File

@ -13,50 +13,59 @@ import MainControls from './MainControls'
const mapStateToProps = (state) => ({
input: state.input,
safeInput: Selectors.safeInput(state)
safeInput: Selectors.safeInput(state),
})
const mapDispatchToProps = dispatch => ({
onChangeOriginal: (text) => dispatch(Actions.updateOriginalInput(text)),
onChangeFinal: (text) => dispatch(Actions.updateFinalInput(text))
onChangeFinal: (text) => dispatch(Actions.updateFinalInput(text)),
})
class Main extends React.Component {
render () {
class Main extends React.Component {
constructor() {
super()
}
render () {
return (
<div>
<Header />
<Header/>
<Segment basic padded>
<Grid stackable columns={3}>
<Grid.Column width='3'>
<MainControls />
<Grid.Column width="3">
<MainControls/>
</Grid.Column>
<Grid.Column width='6'>
<Grid.Column width="6">
<Form>
<Form.Field>
<label>Original</label>
<textarea value={this.props.input.original} onChange={event => this.props.onChangeOriginal(event.target.value)} />
<textarea value={this.props.input.original} onChange={event => this.props.onChangeOriginal(event.target.value)}></textarea>
</Form.Field>
</Form>
</Grid.Column>
<Grid.Column width='6'>
<Form>
<Grid.Column width="6">
<Form>
<Form.Field>
<label>Final</label>
<textarea value={this.props.input.final} onChange={event => this.props.onChangeFinal(event.target.value)} />
<textarea value={this.props.input.final} onChange={event => this.props.onChangeFinal(event.target.value)}></textarea>
</Form.Field>
</Form>
</Grid.Column>
</Grid>
</Segment>
<Footer />
<Footer/>
</div>
)
}
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Main)

View File

@ -7,30 +7,31 @@ import * as Actions from '../actions'
import * as Selectors from '../selectors'
const mapStateToProps = (state) => ({
format: state.format,
format: state.format,
isMarkdownFormat: Selectors.isMarkdownFormat(state),
saveStatus: state.saveStatus
})
const mapDispatchToProps = dispatch => ({
onSetPlaintextFormat: (format) => dispatch(Actions.setPlaintextFormat()),
onSetMarkdownFormat: (format) => dispatch(Actions.setMarkdownFormat()),
onSetMarkdownFormat: (format) => dispatch(Actions.setMarkdownFormat()),
// returns an id for the record to be saved
onCompare: () => dispatch(Actions.compare())
//returns an id for the record to be saved
onCompare: () => dispatch(Actions.compare())
})
class MainControls extends React.Component {
onClickMarkdownFormat () {
if (this.props.isMarkdownFormat) {
onClickMarkdownFormat() {
if (this.props.isMarkdownFormat)
this.props.onSetPlaintextFormat()
} else {
else
this.props.onSetMarkdownFormat()
}
}
render () {
render() {
return (
<Segment.Group>
<Segment >
@ -38,8 +39,8 @@ class MainControls extends React.Component {
</Segment>
<Segment >
<Button fluid active={this.props.isMarkdownFormat} type='submit' onClick={this.onClickMarkdownFormat.bind(this)}>
{this.props.isMarkdownFormat ? <Icon name='checkmark' /> : <span />}
<Button fluid active={this.props.isMarkdownFormat} type="submit" onClick={this.onClickMarkdownFormat.bind(this)}>
{this.props.isMarkdownFormat ? <Icon name="checkmark"/> : <span/>}
&nbsp;As Markdown
</Button>
</Segment>
@ -51,4 +52,4 @@ class MainControls extends React.Component {
export default connect(mapStateToProps, mapDispatchToProps)(MainControls)
/*
<a type="button" onClick={this.onClickCompare.bind(this)} className="btn btn-block btn-primary">compare</a> */
<a type="button" onClick={this.onClickCompare.bind(this)} className="btn btn-block btn-primary">compare</a>*/

View File

@ -1,7 +1,8 @@
import React from 'react'
import {connect} from 'react-redux'
import {Message, Icon, Button} from 'semantic-ui-react'
import { Message, Icon, Button} from 'semantic-ui-react'
import { browserHistory} from 'react-router'
import * as Actions from '../actions'
import {Status, StatusError} from '../constants'
@ -10,56 +11,54 @@ const mapStateToProps = (state) => ({
status: state.status
})
const mapDispatchToProps = dispatch => ({
onSave: () => dispatch(Actions.save()),
onReset: () => dispatch(Actions.reset())
})
const SaveStatus = (props) => {
if (props.status.type === Status.SAVING) {
return (
<Message size='tiny' floating compact icon>
<Icon name='circle notched' loading />
<Message.Content>
<Message.Header>Saving diff</Message.Header>
</Message.Content>
</Message>
)
}
if (props.status.type === Status.LOADING) {
return (
<Message size='tiny' floating compact icon>
<Icon name='circle notched' loading />
<Message.Content>
<Message.Header>Loading diff</Message.Header>
</Message.Content>
</Message>
)
} else if (props.status.hasError && props.status.errorType === StatusError.SAVE_ERROR) {
return (
<Message size='tiny' floating compact icon>
<Icon name='exclamation' />
<Message.Content>
<Message.Header>Error saving diff</Message.Header>
{props.status.error.message}
<br />
<Button onClick={props.onSave}>Retry</Button>
</Message.Content>
</Message>
)
} else if (props.status.hasError && props.status.errorType === StatusError.LOAD_ERROR) {
return (
<Message size='tiny' floating compact icon>
<Icon name='exclamation' />
<Message.Content>
<Message.Header>Error loading diff</Message.Header>
{props.status.error.message}
<br />
<Button onClick={props.onReset}>New Diff</Button>
</Message.Content>
</Message>
)
} else return (<div />)
if (props.status.type == Status.SAVING) return (
<Message size='tiny' floating compact icon>
<Icon name='circle notched' loading />
<Message.Content>
<Message.Header>Saving diff</Message.Header>
</Message.Content>
</Message>
)
if (props.status.type == Status.LOADING) return (
<Message size='tiny' floating compact icon>
<Icon name='circle notched' loading />
<Message.Content>
<Message.Header>Loading diff</Message.Header>
</Message.Content>
</Message>
)
else if (props.status.hasError && props.status.errorType == StatusError.SAVE_ERROR) return (
<Message size='tiny' floating compact icon>
<Icon name='exclamation' />
<Message.Content>
<Message.Header>Error saving diff</Message.Header>
{props.status.error.message}
<br/>
<Button onClick={props.onSave}>Retry</Button>
</Message.Content>
</Message>
)
else if (props.status.hasError && props.status.errorType == StatusError.LOAD_ERROR) return (
<Message size='tiny' floating compact icon>
<Icon name='exclamation' />
<Message.Content>
<Message.Header>Error loading diff</Message.Header>
{props.status.error.message}
<br/>
<Button onClick={props.onReset}>New Diff</Button>
</Message.Content>
</Message>
)
else return ( <div></div> )
}
export default connect(mapStateToProps, mapDispatchToProps)(SaveStatus)

View File

@ -1,17 +1,18 @@
import React from 'react'
import markdownCompiler from 'markdown-to-jsx'
import markdownCompiler from 'markdown-to-jsx'
import {diffToHtml} from '../util/dubdiff'
import {diffToString, diffToHtml} from '../util/dubdiff'
const ShowMarkdown = (props) => {
return <div>
{
props.text
? markdownCompiler(props.text)
: props.diff
? markdownCompiler(diffToHtml(props.diff))
: null
{
props.text ?
markdownCompiler(props.text) :
props.diff ?
markdownCompiler(diffToHtml(props.diff)) :
null
}
</div>
}

View File

@ -1,13 +1,14 @@
import React from 'react'
const ShowPlaintext = (props) => {
return <div>
<pre style={{whiteSpace: 'pre-wrap'}}>
{props.text
? props.text
: props.diff
? diffToPre(props.diff)
: null
<pre style={{whiteSpace:'pre-wrap'}}>
{props.text ?
props.text:
props.diff ?
diffToPre(props.diff) :
null
}
</pre>
</div>
@ -15,12 +16,10 @@ const ShowPlaintext = (props) => {
export default ShowPlaintext
function diffToPre (diff) {
function diffToPre(diff) {
return diff.map((part, index) => (
part.added
? <ins key={index}>{part.value}</ins>
: part.removed
? <del key={index}>{part.value}</del>
: <span key={index}>{part.value}</span>
part.added ? <ins key={index}>{part.value}</ins> :
part.removed ? <del key={index}>{part.value}</del> :
<span key={index}>{part.value}</span>
))
}

View File

@ -4,9 +4,9 @@ export const Format = {
}
export const Show = {
ORIGINAL: 'ORIGINAL',
FINAL: 'FINAL',
DIFFERENCE: 'DIFFERENCE'
ORIGINAL:'ORIGINAL',
FINAL:'FINAL',
DIFFERENCE:'DIFFERENCE'
}
export const Status = {
@ -21,4 +21,4 @@ export const Status = {
export const StatusError = {
LOAD_ERROR: 'LOAD_ERROR',
SAVE_ERROR: 'SAVE_ERROR'
}
}

View File

@ -1,15 +1,16 @@
import {Format, Show, Status, StatusError} from './constants'
export function input (state, action) {
export function input (state, action ) {
switch (action.type) {
case 'UPDATE_ORIGINAL_INPUT':
return Object.assign({}, state, {original: action.data})
return Object.assign({}, state, {original:action.data})
case 'UPDATE_FINAL_INPUT':
return Object.assign({}, state, {final: action.data})
return Object.assign({}, state, {final:action.data})
case 'CLEAR_INPUT':
return {original: '', final: ''}
return {original:'', final:''}
default:
return state || {original: '', final: ''}
return state || {original:'', final:''}
}
}
@ -22,7 +23,8 @@ export function format (state, action) {
default:
return state || Format.PLAINTEXT
}
}
}
export function show (state, action) {
switch (action.type) {
@ -56,20 +58,18 @@ export function saveStatus (state, action) {
}
*/
// tracks status of the app, especially with respect to loaded and saved user data
//tracks status of the app, especially with respect to loaded and saved user data
export function status (state, action) {
// the status or error type is valid if it is in the list of Status or StatusError types
const isValidStatus = (type) => Status[type] === type
const isValidError = (type) => StatusError[type] === type
//the status or error type is valid if it is in the list of Status or StatusError types
const isValidStatus = (type) => Status[type] == type
const isValidError = (type) => StatusError[type] == type
// the error is cleared when status changes
if (action.type === 'STATUS_SET' && isValidStatus(action.data)) {
return {type: action.data, error: null, hasError: false, errorType: null}
}
// the error is set in addition to the status
else if (action.type === 'STATUS_SET_ERROR' && isValidError(action.data)) {
return Object.assign({}, state, {error: action.error, hasError: true, errorType: action.data})
} else {
return state || {type: Status.EMPTY, hasError: false, error: null}
}
//the error is cleared when status changes
if (action.type == 'STATUS_SET' && isValidStatus(action.data))
return {type:action.data, error: null, hasError: false, errorType: null}
//the error is set in addition to the status
else if (action.type == 'STATUS_SET_ERROR' && isValidError(action.data))
return Object.assign({}, state, {error: action.error, hasError: true, errorType:action.data})
else
return state || {type:Status.EMPTY, hasError: false, error:null}
}

View File

@ -1,12 +1,12 @@
import {Route} from 'react-router'
import React from 'react'
import {Route, IndexRout, Redirect } from 'react-router'
import React from 'react';
import Main from './components/Main'
import Compare from './components/Compare'
var routes = [
<Route key='root' path='/' component={Main} />,
<Route key='compare' path='/:compareId' component={Compare} />
<Route key="root" path="/" component={Main}/>,
<Route key="compare" path="/:compareId" component={Compare}/>
]

View File

@ -1,21 +1,23 @@
// per http://redux.js.org/docs/recipes/ComputingDerivedData.html
//per http://redux.js.org/docs/recipes/ComputingDerivedData.html
import { createSelector } from 'reselect'
import React from 'react'
import {Format, Show} from './constants'
import * as Dubdiff from './util/dubdiff'
const input = (state) => state.input
const format = (state) => state.format
const show = (state) => state.show
const show = (state) => state.show
export const safeInput = createSelector(
[input],
(input) => {
//! !! sanitize the input here and return
//!!! sanitize the input here and return
return input
}
)
@ -23,37 +25,44 @@ export const safeInput = createSelector(
export const isMarkdownFormat = createSelector(
[format],
(format) => {
return format === Format.MARKDOWN
return format == Format.MARKDOWN
}
)
const isShow = (type) => createSelector(
[show],
(show) => {
return show === type
return show == type
}
)
export const isShowOriginal = isShow(Show.ORIGINAL)
export const isShowFinal = isShow(Show.FINAL)
export const isShowDifference = isShow(Show.DIFFERENCE)
export const isShowDifference= isShow(Show.DIFFERENCE)
export const diff = createSelector(
[format, safeInput],
(format, safeInput) => {
if (format === Format.PLAINTEXT) {
return Dubdiff.plaintextDiff(safeInput.original, safeInput.final)
} else if (format === Format.MARKDOWN) {
return Dubdiff.markdownDiff(safeInput.original, safeInput.final)
}
return Dubdiff.plaintextDiff(safeInput.original, safeInput.final)
/*
let diff = JsDiff.diffWords (input.original.replace(/ /g, ' '), input.final.replace(/ /g, ' '))
return diff.map(({added, removed, value})=>({added, removed, value:value.replace(/ /g, ' ')})).map(part => (
part.added ? <ins>{part.value}</ins> :
part.removed ? <del>{part.value}</del> :
<span>{part.value}</span>
))
*/
}
)
/*
html diff
---
diffHtml(parentOriginal, parentFinal) {
create stringOriginal, stringFinal consisting of
create stringOriginal, stringFinal consisting of
}
*/
*/

View File

@ -6,42 +6,44 @@ import {Diff} from 'diff'
// but is preserved and included in the output.
const TOKEN_BOUNDARYS = /([\s,.:])/
class EditorsDiff extends Diff {
constructor (tokenBoundaries = TOKEN_BOUNDARYS) {
constructor (tokenBoundaries=TOKEN_BOUNDARYS) {
super()
this.tokenBoundaries = tokenBoundaries
}
equals (left, right) {
return (
left.string === right.string
left.string == right.string
)
}
// splits the input string into a series of word and punctuation tokens
// each token is associated with an optional trailing array of spaces
//splits the input string into a series of word and punctuation tokens
//each token is associated with an optional trailing array of spaces
tokenize (value) {
let tokens = value.split(this.tokenBoundaries)
let annotatedTokens = []
tokens.forEach(token => {
let annotatedTokens = []
tokens.forEach( token => {
if (isSpace(token)) {
if (annotatedTokens.length === 0) {
annotatedTokens.push({string: '', whitespace: []})
}
if (annotatedTokens.length == 0)
annotatedTokens.push({string:'', whitespace:[]})
let last = annotatedTokens[annotatedTokens.length - 1]
let last = annotatedTokens[annotatedTokens.length-1]
last.whitespace.push(token)
} else {
annotatedTokens.push({string: token, whitespace: []})
}
else {
annotatedTokens.push({string:token, whitespace:[]})
}
})
// this final empty token is necessary for the jsdiff diffing engine to work properly
annotatedTokens.push({string: '', whitespace: []})
//this final empty token is necessary for the jsdiff diffing engine to work properly
annotatedTokens.push({string:'', whitespace:[]})
return annotatedTokens
}
join (annotatedTokens) {
join(annotatedTokens) {
let tokens = []
annotatedTokens.forEach(annotatedToken => {
tokens.push(annotatedToken.string)
@ -51,9 +53,11 @@ class EditorsDiff extends Diff {
})
return tokens.join('')
}
}
}
export default EditorsDiff
const isSpace = str => /[ ]+/.test(str)
const isNewline = str => /[\n]+/.test(str)
const isSpace = str => /[ ]+/.test(str)

View File

@ -1,115 +1,115 @@
import * as JsDiff from 'diff'
import EditorsDiff from './EditorsDiff'
let plaintextDiffer = new EditorsDiff()
let markdownDiffer = new EditorsDiff(/([\s,.:]|[*\[\]()])/)
let markdownDiffer = new EditorsDiff(/([\s,.:]|[*\[\]\(\)])/)
// returns a comparison of the texts as plaintext
export function plaintextDiff (original, final) {
let diff = plaintextDiffer.diff(original, final)
return diff
//returns a comparison of the texts as plaintext
export function plaintextDiff(original, final) {
let diff = plaintextDiffer.diff(original, final)
return diff
}
// returns a comparison of the texts as markdown
export function markdownDiff (original, final) {
let diff = markdownDiffer.diff(original, final)
diff = rewriteMarkdownDiff(diff)
//returns a comparison of the texts as markdown
export function markdownDiff(original, final) {
let diff = markdownDiffer.diff(original, final)
diff = rewriteMarkdownDiff(diff)
return diff
return diff
}
// returns a string version of the diff, with "{+ ... +}" and "[- ... -]"
// representing ins and del blocks
export function diffToString (diff, tags = {added: {start: '{+', end: '+}'}, removed: {start: '[-', end: '-]'}, same: {start: '', end: ''}}) {
export function diffToString(diff, tags={added:{start:'{+', end:'+}'}, removed:{start:'[-', end:'-]'}, same:{start:'', end:''}}) {
return diff.map(({added, removed, value}) => {
let {start, end} = added ? tags.added : (removed ? tags.removed : tags.same)
let string = value
if (Array.isArray(value)) {
string = value.join('')
}
let {start,end} = added ? tags.added : (removed ? tags.removed : tags.same)
return start + string + end
let string = value
if (Array.isArray(value))
string = value.join('')
return start+string+end
}).join('')
}
export function diffToHtml (diff) {
return diffToString(diff, {added: {start: '<ins>', end: '</ins>'}, removed: {start: '<del>', end: '</del>'}, same: {start: '', end: ''}})
export function diffToHtml(diff) {
return diffToString(diff, {added:{start:'<ins>', end:'</ins>'}, removed:{start:'<del>', end:'</del>'}, same:{start:'', end:''}})
}
// Rewrites the given diff to correctly render as markdown, assuming the source
// documents were also valid markdown.
// In essence, moves the markdown formatting elements in or out of the inserted and deleted blocks, as appropriate
// rules:
// 1. if a multiline del block is followed by an ins block,
//rules:
// 1. if a multiline del block is followed by an ins block,
// the first line of the ins block should be inserted at the end of the first line of the del block
// so the markdown will apply to the ins text as it should
// 2. multiline ins and del blocks should be broken up into a series of single line blocks
// 3. after a newline, if an ins or del block begins with a markdown line formatting prefix (eg. for a title or list)
// 2. after a newline, if an ins or del block begins with a markdown line formatting prefix (eg. for a title or list)
// then that prefix should be moved out of the block
// not yet implemented rules:
//not yet implemented rules:
// 3. if an ins or del block spans one half of a bold, italic or link string
// eg. **Hello <del>World** I</del><ins>Darling** she</ins> said
// eg. **Hello <del>World** I</del><ins>Darling** she</ins> said
// the block should be broken up to move the formatting code outside
// OR the whole formatting string could be brought into the block
// eg. **Hello <del>World</del><ins>Darling</ins>** <ins>I</ins><del>she</del> said
function rewriteMarkdownDiff (diff) {
// apply transformation rules
function rewriteMarkdownDiff(diff) {
//apply transformation rules
let transformedDiff = diff
transformedDiff = applyTransformationRuleMultilineDelThenIns(transformedDiff)
transformedDiff = applyTransformationRuleBreakUpDelIns(transformedDiff)
transformedDiff = applyTransformationRuleFormattingPrefix(transformedDiff)
transformedDiff = applyTransformationRuleRemoveEmpty(transformedDiff)
transformedDiff= applyTransformationRule1(transformedDiff)
transformedDiff= applyTransformationRule2(transformedDiff)
return transformedDiff
}
// Transformation rule 1
// 1. if a multiline del block is followed by an ins block,
//Transformation rule 1
// 1. if a multiline del block is followed by an ins block,
// the first line of the ins block should be inserted at the end of the first line of the del block
// so the markdown will apply to the ins text as it should
function applyTransformationRuleMultilineDelThenIns (diff) {
function applyTransformationRule1(diff) {
let transformedDiff = []
const B_ADDED = 'added'
const B_REMOVED = 'removed'
const B_SAME = 'same'
let previousBlockType = null
const B_ADDED='added', B_REMOVED='removed', B_SAME='same'
let previousBlockType = null
let currentBlockType = null
let previousBlockWasMultiline = false
let currentBlockIsMultiline = false
// iterate the input tokens to create the intermediate representation
//iterate the input tokens to create the intermediate representation
diff.forEach((currentBlock) => {
previousBlockType = currentBlockType
previousBlockWasMultiline = currentBlockIsMultiline
currentBlockType = (currentBlock.added ? B_ADDED : (currentBlock.removed ? B_REMOVED : B_SAME))
currentBlockIsMultiline = isMultilineDiffBlock(currentBlock)
// transform rule 1 applys when:
//transform rule 1 applys when:
// the previous block was a del and had multiple lines
// the current block is an ins
if (previousBlockType === B_REMOVED && currentBlockType === B_ADDED && previousBlockWasMultiline) {
// split the first line from the current block
if (previousBlockType == B_REMOVED && currentBlockType == B_ADDED && previousBlockWasMultiline) {
//split the first line from the current block
let currentBlockSplit = splitMultilineDiffBlock(currentBlock)
// pop the previous diff entry
//pop the previous diff entry
let previousBlock = transformedDiff.pop()
// split the first line from the previous block
//split the first line from the previous block
let previousBlockSplit = splitMultilineDiffBlock(previousBlock)
// now add the blocks back, interleaving del and ins blocks
for (let i = 0; i < Math.max(previousBlockSplit.length, currentBlockSplit.length); i++) {
if (i < previousBlockSplit.length) {
//now add the blocks back, interleaving del and ins blocks
for (let i=0; i<Math.max(previousBlockSplit.length, currentBlockSplit.length); i++) {
if (i<previousBlockSplit.length)
transformedDiff.push(previousBlockSplit[i])
}
if (i < currentBlockSplit.length) { transformedDiff.push(currentBlockSplit[i]) }
if (i<currentBlockSplit.length)
transformedDiff.push(currentBlockSplit[i])
}
} else {
// otherwise, we just add the current block to the transformed list
}
else {
//otherwise, we just add the current block to the transformed list
transformedDiff.push(currentBlock)
}
})
@ -117,43 +117,6 @@ function applyTransformationRuleMultilineDelThenIns (diff) {
return transformedDiff
}
// Transformation rule 2
// 2. multiline del and ins blocks should be broken up
// into a series of single line blocks
function applyTransformationRuleBreakUpDelIns (diff) {
let transformedDiff = []
const B_ADDED = 'added'
const B_REMOVED = 'removed'
const B_SAME = 'same'
let blockType = null
let blockIsMultiline = false
// iterate the input tokens to create the intermediate representation
diff.forEach((block) => {
blockType = (block.added ? B_ADDED : (block.removed ? B_REMOVED : B_SAME))
blockIsMultiline = isMultilineDiffBlock(block)
// transform rule applys when:
// the current block is an ins or del and is multiline
if ((blockType === B_REMOVED || blockType === B_ADDED) && blockIsMultiline) {
// split the first line from the current block
let blockSplit = splitMultilineDiffBlock(block)
blockSplit.forEach(blockSplitLine => transformedDiff.push(blockSplitLine))
} else {
// otherwise, we just add the current block to the transformed list
transformedDiff.push(block)
}
})
return transformedDiff
}
// Transformation rule number 4: remove empty blocks
function applyTransformationRuleRemoveEmpty (diff) {
return diff.filter(({value}) => value.length > 0)
}
// matches markdown prefixes that affect the formatting of the whole subsequent line
// ^ - start of line
@ -163,73 +126,102 @@ function applyTransformationRuleRemoveEmpty (diff) {
// |([ \t]+[\*\+-]) - unordered lists
// |([ \t]+[0-9]+\.) - numeric lists
// )?
// [ \t]+ - trailing whitespace
const MARKDOWN_PREFIX = /^([ \t]*>)*(([ \t]*#*)|([ \t]*[*+\-])|([ \t]*[\d]+\.))?[ \t]+/
// [ \t]* - trailing whitespace
const MARKDOWN_PREFIX = /^([ \t]*\>)*(([ \t]*#*)|([ \t]*[\*\+-])|([ \t]*[\d]+\.))?[ \t]*/
// matches strings that end with a newline followed by some whitespace
//matches strings that end with a newline followed by some whitespace
const NEWLINE_SUFFIX = /\n\s*$/
// transformation rule 3:
// transformation rule 2:
// after a newline, if an ins or del block begins with a markdown line formatting prefix (eg. for a title or list)
// then that prefix should be moved out of the block
// also, if an ins block begins with a formatting prefix and follows immediately after a del block that follows a newline,
// the prefix should be moved out of the block _and_ an extra newline character should be added to the beginning of it
function applyTransformationRuleFormattingPrefix (diff) {
function applyTransformationRule2(diff) {
let transformedDiff = []
let isNewline = true
let newlineString = '\n'
// iterate the input tokens to create the intermediate representation
//iterate the input tokens to create the intermediate representation
diff.forEach((currentBlock) => {
if (isNewline && (currentBlock.added || currentBlock.removed)) {
if (isNewline && (currentBlock.added || currentBlock.removed) ) {
let match = currentBlock.value.match(MARKDOWN_PREFIX)
if (match) {
let preBlock = {value: match[0]}
let postBlock = {added: currentBlock.added, removed: currentBlock.removed, value: currentBlock.value.substring(match[0].length)}
let preBlock = {value:match[0]}
let postBlock = {added:currentBlock.added, removed:currentBlock.removed, value:currentBlock.value.substring(match[0].length)}
if (currentBlock.added) {
let newlineBlock = {value: newlineString}
transformedDiff.push(newlineBlock)
}
transformedDiff.push(preBlock)
transformedDiff.push(postBlock)
} else {
}
else {
transformedDiff.push(currentBlock)
}
} else {
}
else {
transformedDiff.push(currentBlock)
isNewline = NEWLINE_SUFFIX.test(currentBlock.value)
if (isNewline) {
if (isNewline)
newlineString = currentBlock.value.match(NEWLINE_SUFFIX)[0]
}
}
})
return transformedDiff
}
// returns true if the given diff block contains a newline element
function isMultilineDiffBlock ({value}) {
return value.indexOf('\n') !== -1
//returns true if the given diff block contains a newline element
function isMultilineDiffBlock({value}) {
return value.indexOf('\n') != -1
}
// returns an array of diff blocks that have the same added, removed fields as the given one
// but with the string split by newlines
// if the diff block has no newlines, an array containing only that diff will be returned
// if the diff block has newlines, the resulting array will have a series of blocks,
// consisting of the block text, interleaved with newlines
// ,
// each of which begin with a newline
// if the diff block begins with a newline, the returned array will begin with an empty diff
function splitMultilineDiffBlock ({added, removed, value}) {
let lines = value.split('\n')
let blocks = []
// lines = lines.filter(line=>line.length>0)
lines.forEach((line, index) => {
blocks.push({added, removed, value: line})
if (index < lines.length - 1) blocks.push({value: '\n'})
})
//returns an array of diff blocks that have the same added, removed fields as the given one
//but with the string split by newlines
//if the diff block has no newlines, an array containing only that diff will be returned
//if the diff block has newlines, the resulting array will have a series of blocks,
// each of which subsequent to the first block will begin with a newline
//if the diff block begins with a newline, the returned array will begin with an empty diff
function splitMultilineDiffBlock({added, removed, value}) {
//find the indices of the diff block that coorespond to newlines
const splits = indicesOf(value, c => (c=='\n') )
splits.push(value.length)
//create a range from each index
const ranges = splits.reduce(
//the accumulator is a structure with the last index and the list of ranges
//the ranges are a {start, end} structure
({last, ranges}, i) => {
ranges = ranges.concat([{start:last, end:i}])
return {last:i, ranges}
},
//start with the zero index and an empty array
{last: 0, ranges:[]}
).ranges
//map the ranges into blocks
const blocks = ranges.map(
//each block is the same as the given original block, but with the values split at newlines
({start, end}) => ({added, removed, value:value.substring(start, end)})
)
//console.log({value, splits, ranges, blocks})
return blocks
}
//collect all the indices of the given string that satisfy the test function
const indicesOf = (string, test) => string.split('').reduce(
//add indexes that satisfy the test function to the array
(acc, x, i) => (test(x) ? acc.concat([i]) : acc ),
//start with the empty array
[]
)

View File

@ -1,37 +1,30 @@
var Path = require('path')
var Path = require('path');
var srcRoot = Path.join(__dirname, '..')
// there should be some option for distribution / optimization?
var config = {
presets: ['node6', 'react'],
// enable source maps for non-production instances
sourceMaps: (process.env.NODE_ENV !== 'production' ? 'both' : false),
// highlightCode: false,
//there should be some option for distribution / optimization?
var config = {
presets: ["node6", "react"],
//enable source maps for non-production instances
sourceMaps: (process.env.NODE_ENV !== "production" ? "both" : false),
//highlightCode: false,
sourceRoot: srcRoot,
only: /src/
}
};
require('babel-core/register')(config)
require('babel-core/register')(config);
var piping = require('piping')
main()
function main () {
// Enable piping for non-production environments
if (process.env.NODE_ENV !== 'production') {
// piping will return false for the initial invocation
// the app will be run again in an instance managed by piping
if (!piping({hook: true, includeModules: false})) {
return
}
}
try {
require('./index.js')
} catch (error) {
console.error(error.stack)
// Enable piping for non-production environments
if (process.env.NODE_ENV !== "production") {
if (!require("piping")({hook: true, includeModules: false})) {
return;
}
}
try {
require('./index.js');
}
catch (error) {
console.error(error.stack);
}

View File

@ -3,85 +3,89 @@ import jf from 'jsonfile'
import fs from 'fs'
import uuid from 'uuid'
const router = express.Router()
router.get('/:id', showComparison)
router.post('/:id', createComparisonWithId)
router.post('/', createComparison)
// return the comparison given an id, if it exsits
function showComparison (req, res) {
//return the comparison given an id, if it exsits
function showComparison(req, res) {
const id = req.params.id
return readRecord(res, id)
}
// Creates a new comparison
function createComparison (req, res) {
// generate a new id
const id = uuid()
const {a, b} = req.body
function createComparison(req, res) {
//generate a new id
const id = uuid()
const {a,b} = req.body
return writeRecord(res, id, {a, b, id})
return writeRecord(res, id, {a,b,id})
}
// Creates a new comparison
function createComparisonWithId (req, res) {
// use the id provided in the req
function createComparisonWithId(req, res) {
//use the id provided in the req
const id = req.params.id
const {a, b} = req.body
return writeRecord(res, id, {a, b, id})
}
// reads the record from the database
function readRecord (res, id, data) {
// generate a filename
//reads the record from the database
function readRecord(res, id, data) {
//generate a filename
const filename = fnData(id)
// check if that file exists
//check if that file exists
fs.exists(filename, function (exists) {
// if the file does not exist, return a 404
if (!exists) return res.status(404).send(`Data id ${id} not found.`)
//if the file does not exist, return a 404
if (!exists) return res.status(404).send(`Data id ${id} not found.`)
// otherwise, read the file as JSON
jf.readFile(filename, function (err, data) {
if (err) { return handleError(res, err) }
//otherwise, read the file as JSON
jf.readFile(filename, function(err, data) {
if(err) { return handleError(res, err) }
// and return
//and return
return res.json(data)
})
})
}
// writes the record to the database, if it doesn't exist
function writeRecord (res, id, data) {
// look up its filename
//writes the record to the database, if it doesn't exist
function writeRecord(res, id, data) {
//look up its filename
var filename = fnData(id)
// need to test that the file does not exist
//need to test that the file does not exist
// check if that file exists
//check if that file exists
fs.exists(filename, (exists) => {
// if the file already exists, return a 405
if (exists) return res.status(405).send(`Data id ${id} is already in use.`)
//if the file already exists, return a 405
if (exists) return res.status(405).send(`Data id ${id} is already in use.`)
// and write it to the filesystem
//and write it to the filesystem
jf.writeFile(filename, data, (err) => (
err
? handleError(res, err)
// if successful, return the comparison object
: res.status(201).json(data)
err ?
handleError(res, err) :
//if successful, return the comparison object
res.status(201).json(data)
))
})
}
module.exports = router
function handleError (res, err) {
function handleError(res, err) {
console.log(err)
return res.send(500, err)
}
// returns a filename for the given comparison
function fnData (id) {
return `./data/${id}.json`

View File

@ -7,77 +7,84 @@ import fetch from 'isomorphic-fetch'
import comparisonRouter from './comparison'
import * as reducers from '../common/reducers'
import {Status, StatusError} from '../common/constants'
import render from './render'
// set use port 8080 for dev, 80 for production
const PORT = (process.env.NODE_ENV !== 'production' ? 8080 : 80)
const PORT = 8080
const app = express()
// serve the dist static files at /dist
//serve the dist static files at /dist
app.use('/dist', express.static(path.join(__dirname, '..', '..', 'dist')))
// serve the comparison api at /api/compare
//serve the comparison api at /api/compare
app.use(bodyParser.json())
app.use('/api/compare', comparisonRouter)
app.use('/api/compare', comparisonRouter);
// the following routes are for server-side rendering of the app
// we should render the comparison directly from the server
// this loading logic could be moved into ../common/actions because it is isomorphic
//the following routes are for server-side rendering of the app
//we should render the comparison directly from the server
//this loading logic could be moved into ../common/actions because it is isomorphic
app.route('/:comparisonId')
.get((req, res) => {
const store = createSessionStore()
const endpointUri = `http://localhost:${PORT}/api/compare/${req.params.comparisonId}`
// fetch the comparison
//fetch the comparison
fetch(endpointUri)
.then(response => {
if (response.ok) {
if (response.ok)
return response.json()
} else {
response.text().then(() => {
const error = {message: `${response.status}: ${response.statusText}`}
else {
response.text().then( () => {
const error = {message:`${response.status}: ${response.statusText}`}
initAndRenderError(error, store, req, res)
})
}
})
.then(({a, b}) => {
initAndRenderComparison({a, b}, store, req, res)
.then( ({a,b}) => {
initAndRenderComparison({a,b}, store, req, res)
})
.catch(error => {
.catch( error => {
initAndRenderError(error, store, req, res)
})
})
app.route('/')
.get((req, res) => {
render(createSessionStore(), req, res)
})
app.listen(PORT, function () {
console.log(`Server listening on port ${PORT}.`)
console.log('Server listening on port 8080.')
})
// creates the session store
function createSessionStore () {
// create the redux store
//creates the session store
function createSessionStore() {
//create the redux store
return Redux.createStore(
Redux.combineReducers(reducers)
)
}
function initAndRenderComparison ({a, b}, store, req, res) {
function initAndRenderComparison({a,b}, store, req, res) {
store.dispatch({type: 'UPDATE_ORIGINAL_INPUT', data: a})
store.dispatch({type: 'UPDATE_FINAL_INPUT', data: b})
store.dispatch({type: 'STATUS_SET', data: Status.CLEAN})
render(store, req, res)
}
function initAndRenderError (error, store, req, res) {
function initAndRenderError(error, store, req, res) {
store.dispatch({type: 'STATUS_SET', data: Status.EMPTY})
store.dispatch({type: 'STATUS_SET_ERROR', data: StatusError.LOAD_ERROR, error})
render(store, req, res)
}
}

View File

@ -5,11 +5,12 @@ import { match, RouterContext } from 'react-router'
import routes from '../common/routes.js'
export default function render (store, req, res) {
export default function render(store, req, res) {
// Send the rendered page back to the client
match({ routes, location: req.url }, (error, redirectLocation, renderProps) => {
if (error) {
res.status(500).send(errorTemplate('Routing Error:', error.message))
res.status(500).send(renderError('Routing Error:', error.message))
} else if (redirectLocation) {
res.redirect(302, redirectLocation.pathname + redirectLocation.search)
} else if (renderProps) {
@ -17,7 +18,7 @@ export default function render (store, req, res) {
try {
const html = renderToString(
<Provider store={store}>
<RouterContext {...renderProps} />
<RouterContext {...renderProps} />
</Provider>
)
@ -25,10 +26,12 @@ export default function render (store, req, res) {
const initialState = store.getState()
// and send
res.status(200).send(appTemplate(html, initialState))
} catch (ex) {
console.log('Render Exception:', ex)
}
catch(ex) {
console.log("Render Exception:",ex)
res.status(500).send(errorTemplate('Render Exception', ex.message, ex))
}
} else {
res.status(404).send(errorTemplate('Not found', `${req.url} not found.`))
}
@ -36,7 +39,7 @@ export default function render (store, req, res) {
}
const pageTemplate = (body) => {
return `
return `
<!doctype html>
<html>
<head>
@ -51,6 +54,7 @@ const pageTemplate = (body) => {
<link rel="icon" type="image/png" sizes="96x96" href="dist/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="16x16" href="dist/favicon-16x16.png">
</head>
<body>
${body}
@ -59,23 +63,25 @@ const pageTemplate = (body) => {
`
}
function errorTemplate (title, message, exception) {
function errorTemplate(title, message, exception) {
return pageTemplate(`
<h1>${title}</h1>
<p>${message}</p>
${exception
? `<pre>${exception.toString()}</pre>`
: ``
${exception ?
`<pre>${exception.toString()}</pre>`:
``
}
`)
}
function appTemplate (html, initialState) {
return pageTemplate(`
function appTemplate(html, initialState) {
return pageTemplate(`
<div id="root">${html}</div>
<script>
window.__INITIAL_STATE__ = "${encodeURI(JSON.stringify(initialState, null, 2))}"
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)}
</script>
<!-- <script>__REACT_DEVTOOLS_GLOBAL_HOOK__ = parent.__REACT_DEVTOOLS_GLOBAL_HOOK__</script> -->
<script type="text/javascript" src="dist/browser-bundle.js"></script>

View File

@ -1,85 +1,74 @@
/* eslint-env node, mocha */
/* global expect */
/* eslint no-console: 0 */
'use strict'
/*eslint-env node, mocha */
/*global expect */
/*eslint no-console: 0*/
'use strict';
import chai from 'chai'
import {markdownDiff, diffToString} from '../src/common/util/dubdiff'
let diff = (a, b) => diffToString(markdownDiff(a, b))
let diff = (a,b) => diffToString(markdownDiff(a,b))
const expect = chai.expect // eslint-disable-line no-unused-vars
const expect = chai.expect
describe('dubdiff', () => {
beforeEach(() => {
})
let db;
it('plaintext diffs consecutive words', () => {
beforeEach(() => {
});
it('plaintext diffs consecutive words', ()=>{
expect(diff(
'This is a smlb sentnce with no errors.',
'This is a simple sentence with no errors.'
)).to.equal('This is a [-smlb sentnce -]{+simple sentence +}with no errors.')
})
it('plaintext diffs with word deletion', () => {
it('plaintext diffs with word deletion', ()=>{
expect(diff(
'Gonna delete a word.',
'Gonna delete word.'
)).to.equal('Gonna delete [-a -]word.')
})
it('plaintext diffs with word insertion', () => {
it('plaintext diffs with word insertion', ()=>{
expect(diff(
'Gonna delete word.',
'Gonna delete a word.'
)).to.equal('Gonna delete {+a +}word.')
})
it('reorganizes insertions after multiline deletions', () => {
it('reorganizes insertions after multiline deletions', ()=>{
expect(diff(
`# Title
other`,
`# Subtitle`
)).to.equal('# [-Title-]{+Subtitle+}\n[-other-]')
)).to.equal('# [-Title-]{+Subtitle+}[-\nother-]')
})
it('pulls prefixes out of ins or del blocks after newline', () => {
it('pulls prefixes out of ins or del blocks after newline', () => {
expect(diff(
'# Title\n > hello',
'# Title\n - goodbye'
)).to.equal('# Title\n > [-hello-]\n - {+goodbye+}')
})
it('respects bold and italic boundaries', () => {
it('respects bold and italic boundaries', () => {
expect(diff(
'This *word* **isn\'t** changed.',
'This *other one* **is** changed.'
)).to.equal('This *[-word-]{+other one+}* **[-isn\'t-]{+is+}** changed.')
})
it('respects link boundaries in link text', () => {
it('respects link boundaries in link text', () => {
expect(diff(
'This [link](https://somewhere.com) is the same.',
'This [target](https://somewhere.com) changed.'
)).to.equal('This [[-link-]{+target+}](https://somewhere.com) [-is the same-]{+changed+}.')
})
it('respects link boundaries in link target', () => {
it('respects link boundaries in link target', () => {
expect(diff(
'This [link](https://somewhere.com) is the same.',
'This [link](https://somewhere.org) changed.'
)).to.equal('This [link](https://somewhere.[-com-]{+org+}) [-is the same-]{+changed+}.')
})
it('deletes a title', () => {
expect(diff(
'Hello\n# Title 1\n# Title 2',
'Hello\n# Title 2'
)).to.equal('Hello\n# Title [-1-]\n# [-Title -]2')
})
it('deletes a more different title', () => {
expect(diff(
'Hello\n# Filbert\n# Title 2',
'Hello\n# Title 2'
)).to.equal('Hello\n# [-Filbert-]\n# Title 2')
})
})
})

View File

@ -1,41 +1,42 @@
/* eslint-env node, mocha */
/* global expect */
/* eslint no-console: 0 */
'use strict'
/*eslint-env node, mocha */
/*global expect */
/*eslint no-console: 0*/
'use strict';
import chai from 'chai'
import {plaintextDiff, diffToString} from '../src/common/util/dubdiff'
let diff = (a, b) => diffToString(plaintextDiff(a, b))
let diff = (a,b) => diffToString(plaintextDiff(a,b))
const expect = chai.expect // eslint-disable-line no-unused-vars
const expect = chai.expect
describe('dubdiff', () => {
beforeEach(() => {
})
it('diffs single words', () => {
beforeEach(() => {
});
it('diffs single words', ()=>{
expect(diff(
'This is a smlb sentence.',
'This is a simple sentence.'
)).to.equal('This is a [-smlb -]{+simple +}sentence.')
})
it('diffs consecutive words', () => {
it('diffs consecutive words', ()=>{
expect(diff(
'This is a smlb sentnce with no errors.',
'This is a simple sentence with no errors.'
)).to.equal('This is a [-smlb sentnce -]{+simple sentence +}with no errors.')
})
it('diffs with word deletion', () => {
it('diffs with word deletion', ()=>{
expect(diff(
'Gonna delete a word.',
'Gonna delete word.'
)).to.equal('Gonna delete [-a -]word.')
})
it('diffs with word insertion', () => {
it('diffs with word insertion', ()=>{
expect(diff(
'Gonna add word.',
'Gonna add a word.'
@ -65,4 +66,4 @@ describe('dubdiff', () => {
'Hello, world.'
)).to.equal('Hello{+, +}world.')
})
})
})

View File

@ -13,10 +13,10 @@ let config = {
loader: 'babel-loader',
query: {
presets: ['es2015-native-modules', 'react'],
compact: 'true'
compact: "true"
}
},
{ test: /\.json$/, loader: 'json-loader' }
{ test: /\.json$/, loader: "json-loader" },
]
},
node: {
@ -24,12 +24,13 @@ let config = {
net: 'empty',
tls: 'empty'
}
};
if (process.env.NODE_ENV == "production") {
config.devtool = "cheap-module-source-map"
}
else {
config.devtool = "eval"
}
if (process.env.NODE_ENV === 'production') {
config.devtool = 'cheap-module-source-map'
} else {
config.devtool = 'eval'
}
module.exports = config
module.exports = config;