├── .gitignore ├── .jshintrc ├── .travis.yml ├── LICENSE.txt ├── README.md ├── app.js ├── app.scss ├── client ├── AuthenticationClient.js ├── Collaborators.js ├── CommentCommand.js ├── CommentComponent.js ├── CommentTool.js ├── Cover.js ├── Dashboard.js ├── EditNote.js ├── EnterName.js ├── FileClient.js ├── Header.js ├── IndexSection.js ├── LoginStatus.js ├── MarkCommand.js ├── MarkTool.js ├── NoteInfo.js ├── NoteItem.js ├── NoteLoader.js ├── NoteReader.js ├── NoteSection.js ├── NoteSummary.js ├── NoteWriter.js ├── NotesApp.js ├── NotesDocumentClient.js ├── NotesRouter.js ├── Notification.js ├── ReadNote.js ├── RequestEditAccess.js ├── RequestLogin.js ├── SettingsSection.js ├── TodoCommand.js ├── TodoComponent.js ├── TodoTool.js └── Welcome.js ├── config └── default.json ├── create-upload-folder.js ├── data ├── defaultSeed.js └── devSeed.js ├── db └── migrations │ ├── 20151222003441_changes.js │ ├── 20160221114137_users.js │ ├── 20160221114148_documents.js │ ├── 20160221114148_sessions.js │ └── 20160221114148_snapshots.js ├── favicon.ico ├── gulpfile.js ├── i18n └── en │ └── index.js ├── index.html ├── knexfile.js ├── model ├── Note.js ├── exampleNote.js ├── exampleNote.old.js ├── exampleNoteChangeset.js ├── newNote.js └── noteSchema.js ├── package.json ├── queries.sql ├── seed.js ├── server.js ├── server ├── AuthenticationEngine.js ├── AuthenticationServer.js ├── ChangeStore.js ├── Database.js ├── DocumentStore.js ├── FileServer.js ├── FileStore.js ├── Mail.js ├── NotesDocumentEngine.js ├── NotesDocumentServer.js ├── NotesEngine.js ├── NotesServer.js ├── SessionStore.js ├── SnapshotStore.js └── UserStore.js ├── styles ├── _collaborators.scss ├── _comment.scss ├── _cover.scss ├── _dashboard.scss ├── _enter-name.scss ├── _header.scss ├── _login-status.scss ├── _mark.scss ├── _note-item.scss ├── _note-summary.scss ├── _note-writer.scss ├── _notes.scss ├── _notification.scss ├── _request-login.scss ├── _shared.scss ├── _todo.scss ├── _welcome.scss └── assets │ └── img │ └── welcome_header.png └── test ├── db.js ├── qunit_extensions.js ├── run.js └── server ├── AuthenticationEngine.test.js ├── ChangeStore.test.js ├── DocumentStore.test.js ├── SessionStore.test.js ├── SnapshotEngine.test.js ├── SnapshotStore.test.js └── UserStore.test.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | # DB files 31 | *.sqlite3 32 | 33 | # Production config 34 | config/production.json 35 | 36 | dist 37 | dist/doc 38 | uploads 39 | tmp -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true, 3 | "node": true, 4 | "devel": true, 5 | "latedef": true, 6 | "undef": true, 7 | "unused": true, 8 | "sub": true, 9 | "predef": [ 10 | "localStorage", 11 | "React", 12 | "WebSocket", 13 | "window", 14 | "document", 15 | "i18n", 16 | "QUnit" 17 | ] 18 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "5" 4 | - "4" 5 | before_script: 6 | - 'export CHROME_BIN=chromium-browser' 7 | - 'export DISPLAY=:99.0' 8 | - 'sh -e /etc/init.d/xvfb start' 9 | - 'npm install' -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Substance Notes [![Build Status](https://travis-ci.org/substance/notes.svg?branch=master)](https://travis-ci.org/substance/notes) 2 | 3 | Real-time collaborative notes editing. 4 | 5 | # Install 6 | 7 | Clone the repo 8 | 9 | ```bash 10 | git clone https://github.com/substance/notes.git 11 | ``` 12 | 13 | Install dependencies 14 | 15 | ```bash 16 | npm install 17 | ``` 18 | 19 | Seed the db 20 | 21 | ```bash 22 | npm run seed dev 23 | ``` 24 | 25 | Start the app 26 | 27 | ```bash 28 | npm start 29 | ``` 30 | 31 | To login with the test user: 32 | 33 | ```bash 34 | http://localhost:5000/#loginKey=1234 35 | ``` 36 | 37 | # Configuration 38 | 39 | You can configure app for different environments. 40 | To do that just make a new copy of ```default.json``` from ```config``` folder with name of your environment and run: 41 | 42 | ```bash 43 | export NODE_ENV=myEnv 44 | ``` 45 | 46 | For example you can create config/production.json and then run ```export NODE_ENV=production```. 47 | You should run seed after executing this command 48 | 49 | # Bundling 50 | 51 | Server will serve bundled version of app in production mode. So you should execute this command before: 52 | 53 | ```bash 54 | npm run bundle 55 | ``` 56 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | window.SUBSTANCE_DEBUG_RENDERING = true; 2 | 3 | var NotesApp = require('./client/NotesApp'); 4 | var Component = require('substance/ui/Component'); 5 | var $ = window.$ = require('substance/util/jquery'); 6 | 7 | // Start the application 8 | $(function() { 9 | window.app = Component.mount(NotesApp, document.body); 10 | }); 11 | -------------------------------------------------------------------------------- /app.scss: -------------------------------------------------------------------------------- 1 | $fa-font-path: "./fonts" !default; 2 | @import './node_modules/font-awesome/scss/font-awesome'; 3 | @import './node_modules/substance/styles/base/index'; 4 | 5 | // Substance modules 6 | @import './node_modules/substance/styles/components/_all'; 7 | @import './node_modules/substance/packages/_all'; 8 | 9 | @import './styles/_notes'; // app styles 10 | @import './styles/_shared'; // buttons etc. 11 | @import './styles/_header'; 12 | @import './styles/_dashboard'; 13 | @import './styles/_note-item'; 14 | @import './styles/_request-login'; 15 | @import './styles/_enter-name'; 16 | @import './styles/_login-status'; 17 | @import './styles/_welcome'; 18 | @import './styles/_collaborators'; 19 | @import './styles/_notification'; 20 | 21 | // NotesWriter styles 22 | @import './styles/_note-writer'; 23 | @import './styles/_cover'; 24 | @import './styles/_note-summary'; 25 | @import './styles/_todo'; 26 | @import './styles/_mark'; 27 | @import './styles/_comment'; 28 | 29 | /* Shared demo styles */ 30 | 31 | body.sm-fixed-layout { 32 | overflow: hidden; 33 | } 34 | -------------------------------------------------------------------------------- /client/AuthenticationClient.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | var $ = require('substance/util/jquery'); 5 | 6 | /* 7 | HTTP client for talking with AuthenticationServer 8 | */ 9 | 10 | function AuthenticationClient(config) { 11 | this.config = config; 12 | this._requests = {}; 13 | } 14 | 15 | AuthenticationClient.Prototype = function() { 16 | 17 | this.getSession = function() { 18 | return this._session; 19 | }; 20 | 21 | this.getSessionToken = function() { 22 | if (this._session) { 23 | return this._session.sessionToken; 24 | } else return null; 25 | }; 26 | 27 | this.getUser = function() { 28 | if (this._session) { 29 | return this._session.user; 30 | } else return null; 31 | }; 32 | 33 | this.changeName = function(userId, name, cb) { 34 | this._requests['changeName'] = userId+name; 35 | 36 | var path = this.config.httpUrl + 'changename'; 37 | this._request('POST', path, { 38 | userId: userId, 39 | name: name 40 | }, function(err, res) { 41 | // Skip if there has been another request in the meanwhile 42 | if (this._requestInvalid('changeName', userId+name)) return; 43 | 44 | if (err) return cb(err); 45 | // We need to update user.name locally too 46 | this._session.user.name = name; 47 | cb(null, res); 48 | }.bind(this)); 49 | }; 50 | 51 | /* 52 | Returns true if client is authenticated 53 | */ 54 | this.isAuthenticated = function() { 55 | return !!this._session; 56 | }; 57 | 58 | this._requestInvalid = function(reqName, reqParams) { 59 | return this._requests[reqName] !== reqParams; 60 | }; 61 | 62 | /* 63 | Authenticate user 64 | 65 | Logindata consists of an object (usually with login/password properties) 66 | */ 67 | this.authenticate = function(loginData, cb) { 68 | this._requests['authenticate'] = loginData; 69 | 70 | var path = this.config.httpUrl + 'authenticate'; 71 | this._request('POST', path, loginData, function(err, hubSession) { 72 | // Skip if there has been another request in the meanwhile 73 | if (this._requestInvalid('authenticate', loginData)) return; 74 | 75 | if (err) return cb(err); 76 | this._session = hubSession; 77 | cb(null, hubSession); 78 | }.bind(this)); 79 | }; 80 | 81 | /* 82 | Clear user session 83 | 84 | TODO: this should make a logout call to the API to remove the session entry 85 | */ 86 | this.logout = function(cb) { 87 | this._session = null; 88 | cb(null); 89 | }; 90 | 91 | /* 92 | Request a login link for a given email address 93 | */ 94 | this.requestLoginLink = function(data, cb) { 95 | this._requests['requestLoginLink'] = data; 96 | 97 | var path = this.config.httpUrl + 'loginlink'; 98 | this._request('POST', path, data, function(err, res) { 99 | // Skip if there has been another request in the meanwhile 100 | if (this._requestInvalid('requestLoginLink', data)) return; 101 | if (err) return cb(err); 102 | cb(null, res); 103 | }.bind(this)); 104 | }; 105 | 106 | /* 107 | A generic request method 108 | */ 109 | this._request = function(method, url, data, cb) { 110 | var ajaxOpts = { 111 | type: method, 112 | url: url, 113 | contentType: "application/json; charset=UTF-8", 114 | dataType: "json", 115 | success: function(data) { 116 | cb(null, data); 117 | }, 118 | error: function(err) { 119 | // console.error(err); 120 | cb(new Error(err.responseJSON.errorMessage)); 121 | } 122 | }; 123 | if (data) { 124 | ajaxOpts.data = JSON.stringify(data); 125 | } 126 | $.ajax(ajaxOpts); 127 | }; 128 | 129 | }; 130 | 131 | oo.initClass(AuthenticationClient); 132 | 133 | module.exports = AuthenticationClient; 134 | -------------------------------------------------------------------------------- /client/Collaborators.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var forEach = require('lodash/forEach'); 5 | 6 | function Collaborators() { 7 | Component.apply(this, arguments); 8 | } 9 | 10 | Collaborators.Prototype = function() { 11 | 12 | this.didMount = function() { 13 | this._init(); 14 | }; 15 | 16 | this.willReceiveProps = function() { 17 | this.dispose(); 18 | this._init(); 19 | }; 20 | 21 | this._init = function() { 22 | this.props.session.on('collaborators:changed', this.rerender, this); 23 | }; 24 | 25 | this.dispose = function() { 26 | this.props.session.off(this); 27 | }; 28 | 29 | this.render = function($$) { 30 | var el = $$('div').addClass('sc-collaborators'); 31 | 32 | var collaborators = this.props.session.collaborators; 33 | forEach(collaborators, function(collaborator) { 34 | var initials = this._extractInitials(collaborator); 35 | el.append( 36 | $$('div').addClass('se-collaborator sm-collaborator-'+collaborator.colorIndex).attr({title: collaborator.name || 'Anonymous'}).append( 37 | initials 38 | ) 39 | ); 40 | }.bind(this)); 41 | return el; 42 | }; 43 | 44 | this._extractInitials = function(collaborator) { 45 | var name = collaborator.name; 46 | if (!name) { 47 | return 'A'; 48 | } 49 | var parts = name.split(' '); 50 | return parts.map(function(part) { 51 | return part[0].toUpperCase(); // only use the first letter of a part 52 | }); 53 | }; 54 | }; 55 | 56 | Component.extend(Collaborators); 57 | 58 | module.exports = Collaborators; -------------------------------------------------------------------------------- /client/CommentCommand.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var SurfaceCommand = require('substance/ui/SurfaceCommand'); 4 | 5 | var CommentCommand = function(surface) { 6 | SurfaceCommand.call(this, surface); 7 | }; 8 | 9 | CommentCommand.Prototype = function() { 10 | 11 | this.getSelection = function() { 12 | return this.getSurface().getSelection(); 13 | }; 14 | 15 | this.getTargetType = function() { 16 | var sel = this.getSelection(); 17 | if (sel.isNull() || !sel.isPropertySelection()) return null; 18 | var doc = this.getDocument(); 19 | var path = sel.getPath(); 20 | var node = doc.get(path[0]); 21 | // HACK: We should make sure the getCommandState is not called for 22 | // an invalid selection. 23 | if (!node) return 'paragraph'; 24 | var nodeType = node.type; 25 | 26 | if (nodeType === 'comment') { 27 | return 'paragraph'; 28 | } else { 29 | return 'comment'; 30 | } 31 | }; 32 | 33 | this.getCommandState = function() { 34 | var surface = this.getSurface(); 35 | var sel = this.getSelection(); 36 | var disabled = !surface.isEnabled() || sel.isNull() || !sel.isPropertySelection(); 37 | var targetType = this.getTargetType(); 38 | 39 | return { 40 | targetType: targetType, 41 | active: targetType !== 'comment', 42 | disabled: disabled 43 | }; 44 | }; 45 | 46 | // Execute command and trigger transformations 47 | this.execute = function() { 48 | var sel = this.getSelection(); 49 | if (!sel.isPropertySelection()) return; 50 | var surface = this.getSurface(); 51 | var targetType = this.getTargetType(); 52 | var authenticationClient = this.context.authenticationClient; 53 | var user = authenticationClient.getUser(); 54 | if (targetType) { 55 | // A Surface transaction performs a sequence of document operations 56 | // and also considers the active selection. 57 | surface.transaction(function(tx, args) { 58 | args.data = { 59 | type: targetType, 60 | createdAt: new Date().toISOString(), 61 | author: user.name 62 | }; 63 | return surface.switchType(tx, args); 64 | }); 65 | return {status: 'ok'}; 66 | } 67 | }; 68 | }; 69 | 70 | SurfaceCommand.extend(CommentCommand); 71 | 72 | CommentCommand.static.name = 'comment'; 73 | 74 | module.exports = CommentCommand; 75 | -------------------------------------------------------------------------------- /client/CommentComponent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var TextProperty = require('substance/ui/TextPropertyComponent'); 5 | var Icon = require('substance/ui/FontAwesomeIcon'); 6 | var moment = require('moment'); 7 | 8 | function CommentComponent() { 9 | Component.apply(this, arguments); 10 | } 11 | 12 | CommentComponent.Prototype = function() { 13 | 14 | this.render = function($$) { 15 | var author = this.props.node.author; 16 | var date = moment(this.props.createdAt).fromNow(); 17 | var authored = ''+author+'' + ' ' + date; 18 | 19 | return $$('div') 20 | .addClass('sc-comment') 21 | .attr("data-id", this.props.node.id) 22 | .append( 23 | $$('div') 24 | .addClass('se-comment-symbol') 25 | .attr({contenteditable: false}).append( 26 | $$(Icon, {icon: "fa-comment"}) 27 | ), 28 | $$('div') 29 | .addClass('se-authored') 30 | .attr('contenteditable', false) 31 | .html(authored), 32 | $$('div').addClass('se-body').append( 33 | $$(TextProperty, { 34 | doc: this.props.node.getDocument(), 35 | path: [ this.props.node.id, "content"], 36 | }) 37 | ) 38 | ); 39 | }; 40 | 41 | this.getDate = function() { 42 | var date = this.props.node.createdAt; 43 | var result = this.timeSince(new Date(date)) + ' ago'; 44 | return result; 45 | }; 46 | }; 47 | 48 | Component.extend(CommentComponent); 49 | 50 | module.exports = CommentComponent; 51 | -------------------------------------------------------------------------------- /client/CommentTool.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var SurfaceTool = require('substance/ui/SurfaceTool'); 4 | 5 | function CommentTool() { 6 | CommentTool.super.apply(this, arguments); 7 | } 8 | 9 | SurfaceTool.extend(CommentTool); 10 | 11 | CommentTool.static.name = 'comment'; 12 | CommentTool.static.command = 'comment'; 13 | 14 | module.exports = CommentTool; 15 | -------------------------------------------------------------------------------- /client/Cover.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var TextPropertyEditor = require('substance/ui/TextPropertyEditor'); 5 | var NoteSummary = require('./NoteSummary'); 6 | 7 | var Cover = function() { 8 | Cover.super.apply(this, arguments); 9 | }; 10 | 11 | Cover.Prototype = function() { 12 | 13 | this.didMount = function() { 14 | var doc = this.getDocument(); 15 | doc.on('document:changed', this._onDocumentChanged, this); 16 | }; 17 | 18 | this.dispose = function() { 19 | var doc = this.getDocument(); 20 | doc.off(this); 21 | }; 22 | 23 | this.render = function($$) { 24 | var doc = this.getDocument(); 25 | var config = this.context.config; 26 | var noteInfo = this.props.noteInfo.props; 27 | var authors = [noteInfo.author || noteInfo.userId]; 28 | 29 | authors = authors.concat(noteInfo.collaborators); 30 | var metaNode = doc.getDocumentMeta(); 31 | return $$("div").addClass("sc-cover") 32 | .append( 33 | // Editable title 34 | $$(TextPropertyEditor, { 35 | name: 'title', 36 | tagName: "div", 37 | commands: config.titleEditor.commands, 38 | path: [metaNode.id, "title"], 39 | editing: this.props.editing || 'full' 40 | }).addClass('se-title'), 41 | $$('div').addClass('se-separator'), 42 | $$('div').addClass('se-authors').append(authors.join(', ')), 43 | $$(NoteSummary, { 44 | mobile: this.props.mobile, 45 | noteInfo: this.props.noteInfo 46 | }) 47 | ); 48 | }; 49 | 50 | this._onDocumentChanged = function(change) { 51 | // Only rerender if changed happened outside of the title surface. 52 | // Otherwise we would destroy the current selection 53 | 54 | // HACK: update the updatedAt property 55 | this.props.noteInfo.props.updatedAt = new Date(); 56 | 57 | if (change.after && change.after.surfaceId !== 'title') { 58 | this.rerender(); 59 | } 60 | }; 61 | 62 | this.getDocument = function() { 63 | return this.props.doc; 64 | }; 65 | }; 66 | 67 | Component.extend(Cover); 68 | 69 | module.exports = Cover; -------------------------------------------------------------------------------- /client/Dashboard.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DocumentClient = require('./NotesDocumentClient'); 4 | var Err = require('substance/util/Error'); 5 | var Header = require('./Header'); 6 | var Button = require('substance/ui/Button'); 7 | var Layout = require('substance/ui/Layout'); 8 | var Component = require('substance/ui/Component'); 9 | var NoteItem = require('./NoteItem'); 10 | 11 | function Dashboard() { 12 | Component.apply(this, arguments); 13 | 14 | var config = this.context.config; 15 | this.documentClient = new DocumentClient({ 16 | httpUrl: config.documentServerUrl || 'http://'+config.host+':'+config.port+'/api/documents/' 17 | }); 18 | } 19 | 20 | Dashboard.Prototype = function() { 21 | 22 | this.didMount = function() { 23 | this._loadDocuments(); 24 | }; 25 | 26 | this.willReceiveProps = function() { 27 | this._loadDocuments(); 28 | }; 29 | 30 | this.render = function($$) { 31 | var noteItems = this.state.noteItems; 32 | var el = $$('div').addClass('sc-dashboard'); 33 | 34 | el.append($$(Header, { 35 | actions: { 36 | 'newNote': 'New Note' 37 | } 38 | })); 39 | 40 | if (!noteItems) { 41 | return el; 42 | } 43 | 44 | if (noteItems.length > 0) { 45 | el.append(this.renderFull($$)); 46 | } else { 47 | el.append(this.renderEmpty($$)); 48 | } 49 | return el; 50 | }; 51 | 52 | this.renderEmpty = function($$) { 53 | var layout = $$(Layout, { 54 | width: 'medium', 55 | textAlign: 'center' 56 | }); 57 | 58 | layout.append( 59 | $$('h1').html( 60 | 'Almost there' 61 | ), 62 | $$('p').html('Create your first note now. Then invite colleagues and friends to view and edit it simultaneously — just share the URL!'), 63 | $$(Button).addClass('se-new-note-button').append('Create new note') 64 | .on('click', this.send.bind(this, 'newNote')) 65 | ); 66 | 67 | return layout; 68 | }; 69 | 70 | this.renderFull = function($$) { 71 | var noteItems = this.state.noteItems; 72 | var layout = $$(Layout, { 73 | width: 'large' 74 | }); 75 | 76 | layout.append( 77 | $$('div').addClass('se-intro').append( 78 | $$('div').addClass('se-note-count').append( 79 | 'Showing ', 80 | noteItems.length.toString(), 81 | ' notes' 82 | ), 83 | $$(Button).addClass('se-new-note-button').append('New Note') 84 | .on('click', this.send.bind(this, 'newNote')) 85 | ) 86 | ); 87 | 88 | if (noteItems) { 89 | noteItems.forEach(function(noteItem) { 90 | layout.append( 91 | $$(NoteItem, noteItem) 92 | ); 93 | }); 94 | } 95 | return layout; 96 | }; 97 | 98 | this._getUserId = function() { 99 | var authenticationClient = this.context.authenticationClient; 100 | var user = authenticationClient.getUser(); 101 | return user.userId; 102 | }; 103 | 104 | this._getUserName = function() { 105 | var authenticationClient = this.context.authenticationClient; 106 | var user = authenticationClient.getUser(); 107 | return user.name; 108 | }; 109 | 110 | /* 111 | Loads documents 112 | */ 113 | this._loadDocuments = function() { 114 | var self = this; 115 | var documentClient = this.documentClient; 116 | var userId = this._getUserId(); 117 | 118 | documentClient.listUserDashboard(userId, function(err, notes) { 119 | if (err) { 120 | this.setState({ 121 | error: new Err('Dashboard.LoadingError', { 122 | message: 'Documents could not be loaded.', 123 | cause: err 124 | }) 125 | }); 126 | console.error('ERROR', err); 127 | return; 128 | } 129 | 130 | self.extendState({ 131 | noteItems: notes 132 | }); 133 | }.bind(this)); 134 | }; 135 | }; 136 | 137 | Component.extend(Dashboard); 138 | 139 | module.exports = Dashboard; -------------------------------------------------------------------------------- /client/EditNote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var SplitPane = require('substance/ui/SplitPane'); 4 | var Collaborators = require('./Collaborators'); 5 | var Notification = require('./Notification'); 6 | var Header = require('./Header'); 7 | var NoteLoader = require('./NoteLoader'); 8 | var NoteWriter = require('./NoteWriter'); 9 | var inBrowser = require('substance/util/inBrowser'); 10 | 11 | function EditNote() { 12 | NoteLoader.apply(this, arguments); 13 | } 14 | 15 | EditNote.Prototype = function() { 16 | var _super = EditNote.super.prototype; 17 | 18 | this.dispose = function() { 19 | _super.dispose.call(this); 20 | 21 | if (inBrowser) { 22 | document.body.classList.remove('sm-fixed-layout'); 23 | } 24 | }; 25 | 26 | this._updateLayout = function() { 27 | if (inBrowser) { 28 | if (this.props.mobile) { 29 | document.body.classList.remove('sm-fixed-layout'); 30 | } else { 31 | document.body.classList.add('sm-fixed-layout'); 32 | } 33 | } 34 | }; 35 | 36 | this.render = function($$) { 37 | var notification = this.state.notification; 38 | var el = $$('div').addClass('sc-edit-note'); 39 | var main = $$('div'); 40 | var header; 41 | 42 | this._updateLayout(); 43 | 44 | // Configure header 45 | // -------------- 46 | 47 | header = $$(Header, { 48 | mobile: this.props.mobile, 49 | actions: { 50 | 'home': 'My Notes', 51 | 'newNote': 'New Note' 52 | } 53 | }); 54 | 55 | // Notification overrules collaborators 56 | if (notification) { 57 | header.outlet('content').append( 58 | $$(Notification, notification) 59 | ); 60 | } else if (this.state.session) { 61 | header.outlet('content').append( 62 | $$(Collaborators, { 63 | session: this.state.session 64 | }) 65 | ); 66 | } 67 | 68 | // Main content 69 | // -------------- 70 | 71 | // Display top-level errors. E.g. when a doc could not be loaded 72 | // we will display the notification on top level 73 | if (this.state.error) { 74 | main = $$('div').append( 75 | $$(Notification, { 76 | type: 'error', 77 | message: this.state.error.message 78 | }) 79 | ); 80 | } else if (this.state.session) { 81 | var fileClient = this.context.fileClient; 82 | main = $$(NoteWriter, { 83 | noteInfo: this.state.noteInfo, 84 | documentSession: this.state.session, 85 | onUploadFile: fileClient.uploadFile.bind(fileClient) 86 | }).ref('notepad'); 87 | } 88 | 89 | el.append( 90 | $$(SplitPane, {splitType: 'horizontal'}).append( 91 | header, 92 | main 93 | ).ref('splitPane') 94 | ); 95 | return el; 96 | }; 97 | 98 | 99 | this._onCollabClientDisconnected = function() { 100 | this.extendState({ 101 | notification: { 102 | type: 'error', 103 | message: 'Connection lost! After reconnecting, your changes will be saved.' 104 | } 105 | }); 106 | }; 107 | 108 | this._onCollabClientConnected = function() { 109 | this.extendState({ 110 | notification: null 111 | }); 112 | }; 113 | 114 | /* 115 | Extract error message for error object. Also consider first cause. 116 | */ 117 | this._onCollabSessionError = function(err) { 118 | var message = [ 119 | this.i18n.t(err.name) 120 | ]; 121 | if (err.cause) { 122 | message.push(this.i18n.t(err.cause.name)); 123 | } 124 | this.extendState({ 125 | notification: { 126 | type: 'error', 127 | message: message.join(' ') 128 | } 129 | }); 130 | }; 131 | 132 | this._onCollabSessionSync = function() { 133 | if (this.state.notification) { 134 | // Unset notification (error message) 135 | this.extendState({ 136 | notification: null 137 | }); 138 | } 139 | }; 140 | }; 141 | 142 | NoteLoader.extend(EditNote); 143 | 144 | module.exports = EditNote; -------------------------------------------------------------------------------- /client/EnterName.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Header = require('./Header'); 4 | var Component = require('substance/ui/Component'); 5 | var Notification = require('./Notification'); 6 | var Icon = require('substance/ui/FontAwesomeIcon'); 7 | var Input = require('substance/ui/Input'); 8 | var Button = require('substance/ui/Button'); 9 | var Layout = require('substance/ui/Layout'); 10 | 11 | function EnterName() { 12 | Component.apply(this, arguments); 13 | } 14 | 15 | EnterName.Prototype = function() { 16 | 17 | this.render = function($$) { 18 | var el = $$('div').addClass('sc-enter-name'); 19 | var userName = this.props.userSession.user.name; 20 | 21 | var header = $$(Header, { 22 | actions: { 23 | 'home': 'My Notes' 24 | } 25 | }); 26 | 27 | var form = $$(Layout, { 28 | width: 'medium', 29 | textAlign: 'center' 30 | }); 31 | 32 | // If no username present yet 33 | if (!userName) { 34 | form.append( 35 | $$('h1').html( 36 | 'Welcome to Substance Notes' 37 | ) 38 | ); 39 | } else { 40 | form.append( 41 | $$('h1').html( 42 | 'Please provide your name' 43 | ) 44 | ); 45 | } 46 | 47 | 48 | if (this.state.notification) { 49 | form.append($$(Notification, this.state.notification)); 50 | } 51 | 52 | form.append( 53 | $$('div').addClass('se-enter-name').append( 54 | $$(Input, { 55 | type: 'text', 56 | value: userName || '', 57 | placeholder: 'Please enter your name here', 58 | centered: true 59 | }).ref('name') 60 | ), 61 | $$('p').addClass('se-help').append( 62 | 'Your name will show up along with notes you worked on. You can change it any time via the user menu.' 63 | ) 64 | ); 65 | 66 | form.append( 67 | $$(Button, { 68 | disabled: !!this.state.loading // disable button when in loading state 69 | }).append( 70 | $$(Icon, {icon: 'fa-long-arrow-right'}), 71 | ' Continue' 72 | ) 73 | .on('click', this._updateUserName) 74 | ); 75 | 76 | el.append( 77 | header, 78 | form 79 | ); 80 | return el; 81 | }; 82 | 83 | this._updateUserName = function() { 84 | var name = this.refs.name.val(); 85 | var authenticationClient = this.context.authenticationClient; 86 | var userSession = this.props.userSession; 87 | 88 | if (!name) { 89 | this.setState({ 90 | notification: { 91 | type: 'error', 92 | message: 'Please provide a name.' 93 | } 94 | }); 95 | } 96 | 97 | authenticationClient.changeName(userSession.user.userId, name, function(err) { 98 | if(err) { 99 | this.setState({ 100 | notification: { 101 | type: 'error', 102 | message: this.i18n(err.name) 103 | } 104 | }); 105 | return; 106 | } 107 | 108 | userSession.user.name = name; 109 | this.send('userSessionUpdated', userSession); 110 | }.bind(this)); 111 | }; 112 | 113 | }; 114 | 115 | Component.extend(EnterName); 116 | 117 | module.exports = EnterName; -------------------------------------------------------------------------------- /client/FileClient.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | 5 | /* 6 | HTTP client for talking with DocumentServer 7 | */ 8 | function FileClient(config) { 9 | this.config = config; 10 | } 11 | 12 | FileClient.Prototype = function() { 13 | 14 | /* 15 | Upload file to the server 16 | */ 17 | this.uploadFile = function(file, cb) { 18 | 19 | function transferComplete(e) { 20 | if(e.currentTarget.status == 200) { 21 | var data = JSON.parse(e.currentTarget.response); 22 | var path = '/media/' + data.name; 23 | cb(null, path); 24 | } else { 25 | cb(new Error(e.currentTarget.response)); 26 | } 27 | } 28 | 29 | function updateProgress(e) { 30 | if (e.lengthComputable) { 31 | //var percentage = (e.loaded / e.total) * 100; 32 | //self.documentSession.hubClient.emit('upload', percentage); 33 | } 34 | } 35 | 36 | var formData = new window.FormData(); 37 | formData.append("files", file); 38 | var xhr = new window.XMLHttpRequest(); 39 | xhr.addEventListener("load", transferComplete); 40 | xhr.upload.addEventListener("progress", updateProgress); 41 | xhr.open('post', this.config.httpUrl, true); 42 | xhr.send(formData); 43 | }; 44 | 45 | }; 46 | 47 | oo.initClass(FileClient); 48 | 49 | module.exports = FileClient; 50 | -------------------------------------------------------------------------------- /client/Header.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var LoginStatus = require('./LoginStatus'); 5 | var forEach = require('lodash/forEach'); 6 | 7 | function Header() { 8 | Component.apply(this, arguments); 9 | } 10 | 11 | Header.Prototype = function() { 12 | 13 | this.render = function($$) { 14 | var authenticationClient = this.context.authenticationClient; 15 | var el = $$('div').addClass('sc-header'); 16 | var actionEls = []; 17 | 18 | if (this.props.actions) { 19 | forEach(this.props.actions, function(label, actionName) { 20 | actionEls.push( 21 | $$('button').addClass('se-action') 22 | .append(label) 23 | .on('click', this.send.bind(this, actionName)) 24 | ); 25 | }.bind(this)); 26 | } 27 | 28 | var content = []; 29 | if (this.props.content) { 30 | content = content.concat(this.props.content); 31 | } 32 | 33 | el.append( 34 | $$('div').addClass('se-actions').append(actionEls), 35 | $$(LoginStatus, { 36 | user: authenticationClient.getUser() 37 | }), 38 | $$('div').addClass('se-content').append(content) 39 | ); 40 | return el; 41 | }; 42 | }; 43 | 44 | Component.extend(Header); 45 | module.exports = Header; -------------------------------------------------------------------------------- /client/IndexSection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var Dashboard = require('./Dashboard'); 5 | var Welcome = require('./Welcome'); 6 | var EnterName = require('./EnterName'); 7 | 8 | function IndexSection() { 9 | Component.apply(this, arguments); 10 | } 11 | 12 | IndexSection.Prototype = function() { 13 | this.render = function($$) { 14 | var el = $$('div').addClass('sc-index-section'); 15 | var userSession = this.props.userSession; 16 | 17 | if (!userSession) { 18 | el.append($$(Welcome).ref('welcome')); 19 | } else if (userSession.user.name) { 20 | el.append($$(Dashboard, this.props).ref('dashboard')); 21 | } else { 22 | el.append($$(EnterName, this.props).ref('enterName')); 23 | } 24 | return el; 25 | }; 26 | }; 27 | 28 | Component.extend(IndexSection); 29 | 30 | module.exports = IndexSection; -------------------------------------------------------------------------------- /client/LoginStatus.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | 5 | function LoginStatus() { 6 | Component.apply(this, arguments); 7 | } 8 | 9 | LoginStatus.Prototype = function() { 10 | 11 | this.render = function($$) { 12 | var user = this.props.user; 13 | var name = user.name || 'Anonymous'; 14 | var el = $$('div').addClass('sc-login-status se-dropdown'); 15 | el.append( 16 | name, 17 | $$('span').addClass('se-caret fa fa-caret-down') 18 | ); 19 | el.append($$('ul').append( 20 | $$('li').on('click', this._openUserSettings).append('Settings'), 21 | $$('li').on('click', this._logout).append('Logout') 22 | )); 23 | return el; 24 | }; 25 | 26 | this._logout = function() { 27 | this.send('logout'); 28 | }; 29 | 30 | this._openUserSettings = function() { 31 | this.send('settings'); 32 | }; 33 | 34 | }; 35 | 36 | Component.extend(LoginStatus); 37 | 38 | module.exports = LoginStatus; -------------------------------------------------------------------------------- /client/MarkCommand.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AnnotationCommand = require('substance/ui/AnnotationCommand'); 4 | 5 | function MarkCommand() { 6 | MarkCommand.super.apply(this, arguments); 7 | } 8 | 9 | AnnotationCommand.extend(MarkCommand); 10 | 11 | MarkCommand.static.name = 'mark'; 12 | MarkCommand.static.annotationType = 'mark'; 13 | 14 | module.exports = MarkCommand; -------------------------------------------------------------------------------- /client/MarkTool.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var AnnotationTool = require('substance/ui/AnnotationTool'); 4 | 5 | function MarkTool() { 6 | MarkTool.super.apply(this, arguments); 7 | } 8 | 9 | AnnotationTool.extend(MarkTool); 10 | 11 | MarkTool.static.name = 'mark'; 12 | MarkTool.static.command = 'mark'; 13 | 14 | module.exports = MarkTool; -------------------------------------------------------------------------------- /client/NoteInfo.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | 5 | /* 6 | Holds custom info about a note. 7 | 8 | This data is owned by the server, we must find a way to update it 9 | in realtime during an editing session 10 | */ 11 | function NoteInfo(props) { 12 | this.props = props; 13 | 14 | if (!props.updatedBy) { 15 | this.props.updatedBy = 'Anonymous'; 16 | } 17 | } 18 | 19 | oo.initClass(NoteInfo); 20 | module.exports = NoteInfo; -------------------------------------------------------------------------------- /client/NoteItem.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var moment = require('moment'); 5 | 6 | function NoteItem() { 7 | Component.apply(this, arguments); 8 | 9 | if (!this.context.urlHelper) { 10 | throw new Error('NoteItem requires urlHelper.'); 11 | } 12 | } 13 | 14 | NoteItem.Prototype = function() { 15 | 16 | this.render = function($$) { 17 | var el = $$('div').addClass('sc-note-item'); 18 | 19 | var urlHelper = this.context.urlHelper; 20 | var url = urlHelper.openNote(this.props.documentId); 21 | 22 | // Title 23 | el.append( 24 | $$('div').addClass('se-title') 25 | .append( 26 | $$('a') 27 | .attr({href: url}) 28 | .append(this.props.title) 29 | ) 30 | ); 31 | 32 | // TODO: Add HTML preview here 33 | // el.append( 34 | // $$('div').addClass('se-preview').append( 35 | // 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin mattis tincidunt massa, ac lacinia mauris facilisis ut. Cras vitae neque leo. Donec convallis erat rutrum dui sagittis, id bibendum urna tincidunt. Donec sed augue non erat maximus suscipit eu ut turpis. Nam placerat dui nec dictum consequat. Nam eu enim porta, aliquet elit quis, condimentum ipsum. Donec dignissim ac lectus vitae porttitor.....' 36 | // ) 37 | // ); 38 | 39 | // Creator + collaborators |  updatedAt 40 | var authors = []; 41 | authors.push($$('strong').append(this.props.creator || 'Anonymous')); 42 | if (this.props.collaborators.length > 0) { 43 | authors.push(' with '); 44 | authors.push(this.props.collaborators.join(', ')); 45 | } 46 | 47 | var updatedAt = [ 48 | 'Updated ', 49 | moment(this.props.updatedAt).fromNow(), 50 | 'by', 51 | this.props.updatedBy || 'Anonymous' 52 | ]; 53 | 54 | el.append( 55 | $$('div').addClass('se-meta').append( 56 | $$('span').addClass('se-meta-item se-authors').append(authors), 57 | $$('span').addClass('se-meta-item se-updated-at').append(updatedAt.join(' ')), 58 | $$('button').addClass('se-meta-item se-delete').append('Delete') 59 | .on('click', this.send.bind(this, 'deleteNote', this.props.documentId)) 60 | ) 61 | ); 62 | return el; 63 | }; 64 | }; 65 | 66 | Component.extend(NoteItem); 67 | 68 | module.exports = NoteItem; 69 | -------------------------------------------------------------------------------- /client/NoteLoader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var CollabSession = require('substance/collab/CollabSession'); 4 | var JSONConverter = require('substance/model/JSONConverter'); 5 | var CollabClient = require('substance/collab/CollabClient'); 6 | var WebSocketConnection = require('substance/collab/WebSocketConnection'); 7 | var Component = require('substance/ui/Component'); 8 | var Note = require('../model/Note'); 9 | var NoteInfo = require('./NoteInfo'); 10 | var converter = new JSONConverter(); 11 | 12 | /* 13 | Used as a scaffold for EditNote and ReadNote components 14 | 15 | Mainly responsible for managing life cycle and data loading 16 | */ 17 | function RealtimeNote() { 18 | Component.apply(this, arguments); 19 | 20 | var config = this.context.config; 21 | 22 | this.conn = new WebSocketConnection({ 23 | wsUrl: config.wsUrl || 'ws://'+config.host+':'+config.port 24 | }); 25 | 26 | this.collabClient = new CollabClient({ 27 | connection: this.conn, 28 | enhanceMessage: function(message) { 29 | var userSession = this.props.userSession; 30 | if (userSession) { 31 | message.sessionToken = userSession.sessionToken; 32 | } 33 | return message; 34 | }.bind(this) 35 | }); 36 | 37 | this.collabClient.on('disconnected', this._onCollabClientDisconnected, this); 38 | this.collabClient.on('connected', this._onCollabClientConnected, this); 39 | } 40 | 41 | RealtimeNote.Prototype = function() { 42 | 43 | this.getInitialState = function() { 44 | return { 45 | session: null, // CollabSession will be stored here, if null indicates we are in loading state 46 | error: null, // used to display error messages e.g. loading of document failed 47 | notification: null //used to display status messages in topbar 48 | }; 49 | }; 50 | 51 | this.getDocumentId = function() { 52 | return this.props.documentId; 53 | }; 54 | 55 | this.didMount = function() { 56 | // load the document after mounting 57 | this._loadDocument(this.getDocumentId()); 58 | }; 59 | 60 | this.willReceiveProps = function(newProps) { 61 | if (newProps.documentId !== this.props.documentId) { 62 | this.dispose(); 63 | // TODO: Use setState instead? 64 | this.state = this.getInitialState(); 65 | this._loadDocument(this.getDocumentId()); 66 | } 67 | }; 68 | 69 | this.dispose = function() { 70 | if (this.state.session) { 71 | this.state.session.off(this); 72 | this.state.session.dispose(); 73 | } 74 | this.collabClient.off(this); 75 | this.collabClient.dispose(); 76 | }; 77 | 78 | this._onError = function(err) { 79 | this.extendState({ 80 | error: { 81 | type: 'error', 82 | message: this.i18n.t(err.name) 83 | } 84 | }); 85 | }; 86 | 87 | // Some hooks 88 | this._onCollabClientDisconnected = function() { 89 | }; 90 | 91 | this._onCollabClientConnected = function() { 92 | }; 93 | 94 | this._onCollabSessionError = function(/*err*/) { 95 | }; 96 | 97 | this._onCollabSessionSync = function() { 98 | }; 99 | 100 | /* 101 | Loads a document and initializes a CollabSession 102 | */ 103 | this._loadDocument = function(documentId) { 104 | var collabClient = this.collabClient; 105 | var documentClient = this.context.documentClient; 106 | 107 | documentClient.getDocument(documentId, function(err, docRecord) { 108 | if (err) { 109 | this._onError(err); 110 | return; 111 | } 112 | 113 | var doc = new Note(); 114 | doc = converter.importDocument(doc, docRecord.data); 115 | var session = new CollabSession(doc, { 116 | documentId: documentId, 117 | version: docRecord.version, 118 | collabClient: collabClient 119 | }); 120 | 121 | // Listen for errors and sync start events for error reporting 122 | session.on('error', this._onCollabSessionError, this); 123 | session.on('sync', this._onCollabSessionSync, this); 124 | 125 | // HACK: For debugging purposes 126 | window.doc = doc; 127 | window.session = session; 128 | 129 | this.setState({ 130 | noteInfo: new NoteInfo(docRecord), 131 | session: session 132 | }); 133 | }.bind(this)); 134 | }; 135 | }; 136 | 137 | Component.extend(RealtimeNote); 138 | 139 | module.exports = RealtimeNote; -------------------------------------------------------------------------------- /client/NoteReader.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Controller = require('substance/ui/Controller'); 4 | var ContainerEditor = require('substance/ui/ContainerEditor'); 5 | var Cover = require('./Cover'); 6 | 7 | /* Works well on mobile */ 8 | function NoteReader() { 9 | Controller.apply(this, arguments); 10 | } 11 | 12 | NoteReader.Prototype = function() { 13 | 14 | // Custom Render method for your editor 15 | this.render = function($$) { 16 | var config = this.getConfig(); 17 | return $$('div').addClass('sc-notepad').append( 18 | $$('div').addClass('se-note-content').append( 19 | $$(Cover, { 20 | doc: this.doc, 21 | mobile: this.props.mobile, 22 | editing: 'readonly', 23 | noteInfo: this.props.noteInfo 24 | }).ref('cover'), 25 | $$(ContainerEditor, { 26 | doc: this.props.documentSession.doc, 27 | containerId: 'body', 28 | name: 'bodyEditor', 29 | editing: 'readonly', 30 | commands: config.bodyEditor.commands, 31 | textTypes: config.bodyEditor.textTypes 32 | }).ref('bodyEditor') 33 | ) 34 | ); 35 | }; 36 | }; 37 | 38 | Controller.extend(NoteReader); 39 | 40 | NoteReader.static.config = { 41 | i18n: { 42 | 'todo.content': 'Todo' 43 | }, 44 | // Controller specific configuration (required!) 45 | controller: { 46 | // Component registry 47 | components: { 48 | 'paragraph': require('substance/packages/paragraph/ParagraphComponent'), 49 | 'heading': require('substance/packages/heading/HeadingComponent'), 50 | 'comment': require('./CommentComponent'), 51 | 'image': require('substance/packages/image/ImageComponent'), 52 | 'link': require('substance/packages/link/LinkComponent'), 53 | 'todo': require('./TodoComponent'), 54 | 'codeblock': require('substance/packages/codeblock/CodeblockComponent'), 55 | 'blockquote': require('substance/packages/blockquote/BlockquoteComponent') 56 | }, 57 | // Controller commands 58 | commands: [ 59 | require('substance/ui/UndoCommand'), 60 | require('substance/ui/RedoCommand'), 61 | require('substance/ui/SaveCommand') 62 | ] 63 | }, 64 | titleEditor: { 65 | commands: [ 66 | require('substance/packages/emphasis/EmphasisCommand'), 67 | require('substance/packages/text/SwitchTextTypeCommand'), 68 | require('substance/packages/subscript/SubscriptCommand'), 69 | require('substance/packages/superscript/SuperscriptCommand') 70 | ] 71 | }, 72 | // Custom configuration (required!) 73 | bodyEditor: { 74 | commands: [ 75 | require('substance/packages/text/SwitchTextTypeCommand'), 76 | require('substance/packages/strong/StrongCommand'), 77 | require('substance/packages/emphasis/EmphasisCommand'), 78 | require('substance/packages/link/LinkCommand'), 79 | require('substance/packages/image/ImageCommand'), 80 | require('./MarkCommand'), 81 | require('./TodoCommand'), 82 | require('./CommentCommand') 83 | ], 84 | textTypes: [ 85 | {name: 'paragraph', data: {type: 'paragraph'}}, 86 | {name: 'heading1', data: {type: 'heading', level: 1}}, 87 | {name: 'heading2', data: {type: 'heading', level: 2}}, 88 | {name: 'heading3', data: {type: 'heading', level: 3}}, 89 | {name: 'codeblock', data: {type: 'codeblock'}}, 90 | {name: 'blockquote', data: {type: 'blockquote'}} 91 | ] 92 | } 93 | }; 94 | 95 | module.exports = NoteReader; 96 | -------------------------------------------------------------------------------- /client/NoteSection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | 5 | var EnterName = require('./EnterName'); 6 | var EditNote = require('./EditNote'); 7 | var ReadNote = require('./ReadNote'); 8 | 9 | function NoteSection() { 10 | Component.apply(this, arguments); 11 | } 12 | 13 | NoteSection.Prototype = function() { 14 | 15 | this.render = function($$) { 16 | var userSession = this.props.userSession; 17 | var el = $$('div').addClass('sc-note-section'); 18 | 19 | if (userSession && !this.props.mobile) { 20 | var userName = userSession.user.name; 21 | 22 | if (userName) { 23 | el.append($$(EditNote, { 24 | documentId: this.props.route.documentId, 25 | userSession: userSession, 26 | mobile: this.props.mobile 27 | }).ref('editNote')); 28 | } else { 29 | el.append($$(EnterName, { 30 | userSession: userSession 31 | })); 32 | } 33 | } else { 34 | el.append($$(ReadNote, { 35 | documentId: this.props.route.documentId, 36 | userSession: userSession, 37 | mobile: this.props.mobile 38 | }).ref('readNote')); 39 | } 40 | 41 | return el; 42 | }; 43 | 44 | }; 45 | 46 | Component.extend(NoteSection); 47 | 48 | module.exports = NoteSection; -------------------------------------------------------------------------------- /client/NoteSummary.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var Icon = require('substance/ui/FontAwesomeIcon'); 5 | var filter = require('lodash/filter'); 6 | var size = require('lodash/size'); 7 | var moment = require('moment'); 8 | 9 | var NoteSummary = function() { 10 | NoteSummary.super.apply(this, arguments); 11 | }; 12 | 13 | NoteSummary.Prototype = function() { 14 | 15 | this.render = function($$) { 16 | var doc = this.context.controller.getDocument(); 17 | var noteInfo = this.props.noteInfo.props; 18 | 19 | var updatedAt = moment(noteInfo.updatedAt).fromNow(); 20 | var commentsQt = size(doc.getIndex('type').get('comment')); 21 | var commentsLabel = commentsQt == 1 ? 'comment' : 'comments'; 22 | var issueIndex = doc.getIndex('type').get('todo'); 23 | var issuesQt = size(issueIndex); 24 | var resolvedQt = size(filter(issueIndex, function(i){return i.done;})); 25 | 26 | var el = $$('div').addClass('sc-note-summary'); 27 | if (this.props.mobile) { 28 | el.addClass('sm-mobile'); 29 | } 30 | 31 | el.append( 32 | $$('div').addClass('se-item').append( 33 | $$(Icon, {icon: "fa-comment-o"}), 34 | ' ' +commentsQt + ' ' + commentsLabel 35 | ), 36 | $$('div').addClass('se-item').append( 37 | $$(Icon, {icon: "fa-check-square-o"}), 38 | $$('div').addClass('se-issues-bar').append( 39 | $$('div').addClass('se-completed') 40 | .setAttribute('style', 'width: ' + resolvedQt/issuesQt*100 + '%') 41 | ), 42 | resolvedQt + ' of ' + issuesQt 43 | ), 44 | $$('div').addClass('se-item').append( 45 | 'Updated ', 46 | updatedAt, 47 | ' by ', 48 | noteInfo.updatedBy 49 | ) 50 | ); 51 | return el; 52 | }; 53 | }; 54 | 55 | Component.extend(NoteSummary); 56 | 57 | module.exports = NoteSummary; -------------------------------------------------------------------------------- /client/NoteWriter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Controller = require('substance/ui/Controller'); 4 | var ContainerEditor = require('substance/ui/ContainerEditor'); 5 | var SplitPane = require('substance/ui/SplitPane'); 6 | var ScrollPane = require('substance/ui/ScrollPane'); 7 | var Icon = require('substance/ui/FontAwesomeIcon'); 8 | var Toolbar = require('substance/ui/Toolbar'); 9 | var Layout = require('substance/ui/Layout'); 10 | var Cover = require('./Cover'); 11 | var UndoTool = require('substance/ui/UndoTool'); 12 | var RedoTool = require('substance/ui/RedoTool'); 13 | var SwitchTextTypeTool = require('substance/packages/text/SwitchTextTypeTool'); 14 | var StrongTool = require('substance/packages/strong/StrongTool'); 15 | var EmphasisTool = require('substance/packages/emphasis/EmphasisTool'); 16 | var LinkTool = require('substance/packages/link/LinkTool'); 17 | var CodeTool = require('substance/packages/code/CodeTool'); 18 | var ImageTool = require('substance/packages/image/ImageTool'); 19 | var MarkTool = require('./MarkTool'); 20 | var TodoTool = require('./TodoTool'); 21 | var CommentTool = require('./CommentTool'); 22 | 23 | function NoteWriter() { 24 | Controller.apply(this, arguments); 25 | } 26 | 27 | NoteWriter.Prototype = function() { 28 | 29 | // Custom Render method for your editor 30 | this.render = function($$) { 31 | var config = this.getConfig(); 32 | return $$('div').addClass('sc-note-writer').append( 33 | $$(SplitPane, {splitType: 'horizontal'}).append( 34 | // Top area (toolbar) 35 | $$('div').addClass('se-toolbar-wrapper').append( 36 | $$(Layout, {width: 'large', noPadding: true}).append( 37 | $$(Toolbar).append( 38 | $$(Toolbar.Group).append( 39 | $$(SwitchTextTypeTool, {'title': this.i18n.t('switch_text')}), 40 | $$(UndoTool).append($$(Icon, {icon: 'fa-undo'})), 41 | $$(RedoTool).append($$(Icon, {icon: 'fa-repeat'})), 42 | $$(StrongTool).append($$(Icon, {icon: 'fa-bold'})), 43 | $$(EmphasisTool).append($$(Icon, {icon: 'fa-italic'})), 44 | $$(CodeTool).append($$(Icon, {icon: 'fa-code'})), 45 | $$(MarkTool).append($$(Icon, {icon: 'fa-pencil'})), 46 | $$(LinkTool).append($$(Icon, {icon: 'fa-link'})), 47 | $$(TodoTool).append($$(Icon, {icon: 'fa-check-square-o'})), 48 | $$(CommentTool).append($$(Icon, {icon: 'fa-comment'})), 49 | $$(ImageTool).append($$(Icon, {icon: 'fa-image'})) 50 | ) 51 | ) 52 | ) 53 | ), 54 | // Bottom area (content) 55 | $$(ScrollPane, { 56 | scrollbarType: 'native', 57 | scrollbarPosition: 'right' 58 | }).append( 59 | // $$('div').addClass('se-note-content').append( 60 | $$(Layout, { 61 | width: 'large' 62 | }).append( 63 | $$(Cover, { 64 | doc: this.doc, 65 | noteInfo: this.props.noteInfo 66 | }).ref('cover'), 67 | $$(ContainerEditor, { 68 | doc: this.props.documentSession.doc, 69 | containerId: 'body', 70 | name: 'bodyEditor', 71 | commands: config.bodyEditor.commands, 72 | textTypes: config.bodyEditor.textTypes 73 | }).ref('bodyEditor') 74 | ) 75 | ).ref('scrollableContent') 76 | ) 77 | ); 78 | }; 79 | }; 80 | 81 | Controller.extend(NoteWriter); 82 | 83 | NoteWriter.static.config = { 84 | i18n: { 85 | 'todo.content': 'Todo' 86 | }, 87 | // Controller specific configuration (required!) 88 | controller: { 89 | // Component registry 90 | components: { 91 | 'paragraph': require('substance/packages/paragraph/ParagraphComponent'), 92 | 'heading': require('substance/packages/heading/HeadingComponent'), 93 | 'comment': require('./CommentComponent'), 94 | 'image': require('substance/packages/image/ImageComponent'), 95 | 'link': require('substance/packages/link/LinkComponent'), 96 | 'todo': require('./TodoComponent'), 97 | 'codeblock': require('substance/packages/codeblock/CodeblockComponent'), 98 | 'blockquote': require('substance/packages/blockquote/BlockquoteComponent') 99 | }, 100 | // Controller commands 101 | commands: [ 102 | require('substance/ui/UndoCommand'), 103 | require('substance/ui/RedoCommand'), 104 | require('substance/ui/SaveCommand') 105 | ] 106 | }, 107 | titleEditor: { 108 | commands: [ 109 | require('substance/packages/emphasis/EmphasisCommand'), 110 | require('substance/packages/text/SwitchTextTypeCommand'), 111 | require('substance/packages/subscript/SubscriptCommand'), 112 | require('./MarkCommand'), 113 | require('substance/packages/superscript/SuperscriptCommand') 114 | ] 115 | }, 116 | // Custom configuration (required!) 117 | bodyEditor: { 118 | commands: [ 119 | require('substance/packages/text/SwitchTextTypeCommand'), 120 | require('substance/packages/strong/StrongCommand'), 121 | require('substance/packages/emphasis/EmphasisCommand'), 122 | require('substance/packages/code/CodeCommand'), 123 | require('substance/packages/link/LinkCommand'), 124 | require('substance/packages/image/ImageCommand'), 125 | require('./MarkCommand'), 126 | require('./TodoCommand'), 127 | require('./CommentCommand') 128 | ], 129 | textTypes: [ 130 | {name: 'paragraph', data: {type: 'paragraph'}}, 131 | {name: 'heading1', data: {type: 'heading', level: 1}}, 132 | {name: 'heading2', data: {type: 'heading', level: 2}}, 133 | {name: 'heading3', data: {type: 'heading', level: 3}}, 134 | {name: 'codeblock', data: {type: 'codeblock'}}, 135 | {name: 'blockquote', data: {type: 'blockquote'}} 136 | ] 137 | } 138 | }; 139 | 140 | module.exports = NoteWriter; 141 | -------------------------------------------------------------------------------- /client/NotesApp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var each = require('lodash/each'); 4 | var inBrowser = require('substance/util/inBrowser'); 5 | var DefaultDOMElement = require('substance/ui/DefaultDOMElement'); 6 | var Component = require('substance/ui/Component'); 7 | var DocumentClient = require('substance/collab/DocumentClient'); 8 | var AuthenticationClient = require('./AuthenticationClient'); 9 | var FileClient = require('./FileClient'); 10 | var IndexSection = require('./IndexSection'); 11 | var NoteSection = require('./NoteSection'); 12 | var SettingsSection = require('./SettingsSection'); 13 | var NotesRouter = require('./NotesRouter'); 14 | 15 | var I18n = require('substance/ui/i18n'); 16 | I18n.instance.load(require('../i18n/en')); 17 | 18 | function NotesApp() { 19 | Component.apply(this, arguments); 20 | 21 | // EXPERIMENTAL: with server.serveHTML it is now possible to 22 | // provide dynamic configuration information via HTML meta tags 23 | // TODO: we want this to go into a Substance util helper 24 | var config = {}; 25 | var metaTags = window.document.querySelectorAll('meta'); 26 | 27 | each(metaTags, function(tag) { 28 | var name = tag.getAttribute('name'); 29 | var content = tag.getAttribute('content'); 30 | if (name && content) { 31 | config[name] = content; 32 | } 33 | }); 34 | 35 | config.host = config.host || 'localhost'; 36 | config.port = config.port || 5000; 37 | 38 | // Store config for later use (e.g. in child components) 39 | this.config = config; 40 | 41 | this.authenticationClient = new AuthenticationClient({ 42 | httpUrl: config.authenticationServerUrl || 'http://'+config.host+':'+config.port+'/api/auth/' 43 | }); 44 | 45 | this.documentClient = new DocumentClient({ 46 | httpUrl: config.documentServerUrl || 'http://'+config.host+':'+config.port+'/api/documents/' 47 | }); 48 | 49 | this.fileClient = new FileClient({ 50 | httpUrl: config.fileServerUrl || 'http://'+config.host+':'+config.port+'/api/files/' 51 | }); 52 | 53 | this.handleActions({ 54 | 'navigate': this.navigate, 55 | 'newNote': this._newNote, 56 | 'home': this._home, 57 | 'settings': this._settings, 58 | 'deleteNote': this._deleteNote, 59 | 'logout': this._logout, 60 | 'userSessionUpdated': this._userSessionUpdated 61 | }); 62 | 63 | this.router = new NotesRouter(this); 64 | this.router.on('route:changed', this._onRouteChanged, this); 65 | } 66 | 67 | NotesApp.Prototype = function() { 68 | 69 | this._userSessionUpdated = function(userSession) { 70 | console.log('user session updated'); 71 | this.extendState({ 72 | userSession: userSession 73 | }); 74 | 75 | if (this.state.route && this.state.route.section === 'settings') { 76 | this.navigate({section: 'index'}); 77 | } 78 | }; 79 | 80 | this._onRouteChanged = function(route) { 81 | console.log('NotesApp._onRouteChanged', route); 82 | this.navigate(route, {replace: true}); 83 | }; 84 | 85 | 86 | /* 87 | That's the public state reflected in the route 88 | */ 89 | this.getInitialState = function() { 90 | return { 91 | route: undefined, 92 | userSession: undefined, 93 | mobile: this._isMobile() 94 | }; 95 | }; 96 | 97 | this.didMount = function() { 98 | if (inBrowser) { 99 | var _window = DefaultDOMElement.getBrowserWindow(); 100 | _window.on('resize', this._onResize, this); 101 | } 102 | var route = this.router.readRoute(); 103 | // Replaces the current entry without creating new history entry 104 | // or triggering hashchange 105 | this.navigate(route, {replace: true}); 106 | }; 107 | 108 | this.dispose = function() { 109 | this.router.off(this); 110 | }; 111 | 112 | // Life cycle 113 | // ------------------------------------ 114 | 115 | this.__getLoginData = function(route) { 116 | var loginKey = route.loginKey; 117 | var storedToken = this._getSessionToken(); 118 | var loginData; 119 | 120 | if (loginKey) { 121 | loginData = {loginKey: loginKey}; 122 | } else if (storedToken && !this.state.userSession) { 123 | loginData = {sessionToken: storedToken}; 124 | } 125 | return loginData; 126 | }; 127 | 128 | /* 129 | Used to navigate the app based on given route. 130 | 131 | Example route: {section: 'note', id: 'note-25'} 132 | 133 | On app level, never use setState/extendState directly as this may 134 | lead to invalid states. 135 | */ 136 | this.navigate = function(route, opts) { 137 | var loginData = this.__getLoginData(route); 138 | 139 | this._authenticate(loginData, function(err, userSession) { 140 | // Patch route not to include loginKey for security reasons 141 | delete route.loginKey; 142 | 143 | this.extendState({ 144 | route: route, 145 | userSession: userSession 146 | }); 147 | 148 | this.router.writeRoute(route, opts); 149 | }.bind(this)); 150 | }; 151 | 152 | /* 153 | Authenticate based on loginData object 154 | 155 | Returns current userSession if no loginData is given 156 | */ 157 | this._authenticate = function(loginData, cb) { 158 | if (!loginData) return cb(null, this.state.userSession); 159 | this.authenticationClient.authenticate(loginData, function(err, userSession) { 160 | if (err) { 161 | window.localStorage.removeItem('sessionToken'); 162 | return cb(err); 163 | } 164 | this._setSessionToken(userSession.sessionToken); 165 | cb(null, userSession); 166 | }.bind(this)); 167 | }; 168 | 169 | /* 170 | Determines when a mobile view should be shown. 171 | 172 | TODO: Check also for user agents. Eg. on iPad we want to show the mobile 173 | version, even thought he screenwidth may be greater than the threshold. 174 | */ 175 | this._isMobile = function() { 176 | return window.innerWidth < 700; 177 | }; 178 | 179 | this._onResize = function() { 180 | if (this._isMobile()) { 181 | // switch to mobile 182 | if (!this.state.mobile) { 183 | this.extendState({ 184 | mobile: true 185 | }); 186 | } 187 | } else { 188 | if (this.state.mobile) { 189 | this.extendState({ 190 | mobile: false 191 | }); 192 | } 193 | } 194 | }; 195 | 196 | /* 197 | Expose hubClient to all child components 198 | */ 199 | this.getChildContext = function() { 200 | return { 201 | authenticationClient: this.authenticationClient, 202 | documentClient: this.documentClient, 203 | fileClient: this.fileClient, 204 | config: this.config, 205 | urlHelper: this.router 206 | }; 207 | }; 208 | 209 | 210 | // Rendering 211 | // ------------------------------------ 212 | 213 | this.render = function($$) { 214 | var el = $$('div').addClass('sc-notes-app'); 215 | 216 | // Uninitialized 217 | if (this.state.route === undefined) { 218 | console.log('Uninitialized'); 219 | return el; 220 | } 221 | 222 | switch (this.state.route.section) { 223 | case 'note': 224 | el.append($$(NoteSection, this.state).ref('noteSection')); 225 | break; 226 | case 'settings': 227 | el.append($$(SettingsSection, this.state).ref('settingsSection')); 228 | break; 229 | default: // !section || section === index 230 | el.append($$(IndexSection, this.state).ref('indexSection')); 231 | break; 232 | } 233 | return el; 234 | }; 235 | 236 | // Action Handlers 237 | // ------------------------------------ 238 | 239 | this._home = function() { 240 | this.navigate({ 241 | section: 'index' 242 | }); 243 | }; 244 | 245 | this._settings = function() { 246 | this.navigate({ 247 | section: 'settings' 248 | }); 249 | }; 250 | 251 | /* 252 | Create a new note 253 | */ 254 | this._newNote = function() { 255 | var userId = this.state.userSession.user.userId; 256 | this.documentClient.createDocument({ 257 | schemaName: 'substance-note', 258 | // TODO: Find a way not to do this statically 259 | // Actually we should not provide the userId 260 | // from the client here. 261 | info: { 262 | title: 'Untitled', 263 | userId: userId 264 | } 265 | }, function(err, result) { 266 | this.navigate({ 267 | section: 'note', 268 | documentId: result.documentId 269 | }); 270 | }.bind(this)); 271 | }; 272 | 273 | this._deleteNote = function(documentId) { 274 | this.documentClient.deleteDocument(documentId, function(/*err, result*/) { 275 | this._home(); 276 | }.bind(this)); 277 | }; 278 | 279 | /* 280 | Forget current user session 281 | */ 282 | this._logout = function() { 283 | this.authenticationClient.logout(function(err) { 284 | if (err) return alert('Logout failed'); 285 | 286 | var indexRoute = {}; 287 | window.localStorage.removeItem('sessionToken'); 288 | this.extendState({ 289 | userSession: null, 290 | route: indexRoute 291 | }); 292 | this.router.writeRoute(indexRoute); 293 | }.bind(this)); 294 | }; 295 | 296 | // Helpers 297 | // ------------------------------------ 298 | 299 | /* 300 | Store session token in localStorage 301 | */ 302 | this._setSessionToken = function(sessionToken) { 303 | console.log('storing new sessionToken', sessionToken); 304 | window.localStorage.setItem('sessionToken', sessionToken); 305 | }; 306 | 307 | /* 308 | Retrieve last session token from localStorage 309 | */ 310 | this._getSessionToken = function() { 311 | return window.localStorage.getItem('sessionToken'); 312 | }; 313 | }; 314 | 315 | Component.extend(NotesApp); 316 | 317 | module.exports = NotesApp; 318 | -------------------------------------------------------------------------------- /client/NotesDocumentClient.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DocumentClient = require('substance/collab/DocumentClient'); 4 | 5 | /* 6 | HTTP client for talking with DocumentServer 7 | */ 8 | 9 | function NotesDocumentClient() { 10 | NotesDocumentClient.super.apply(this, arguments); 11 | } 12 | 13 | NotesDocumentClient.Prototype = function() { 14 | 15 | this.listUserDashboard = function(userId, cb) { 16 | this._request('GET', '/api/notes/dashboard/user/'+userId, null, cb); 17 | }; 18 | 19 | }; 20 | 21 | DocumentClient.extend(NotesDocumentClient); 22 | 23 | module.exports = NotesDocumentClient; 24 | -------------------------------------------------------------------------------- /client/NotesRouter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Router = require('substance/ui/Router'); 4 | 5 | function NotesRouter(app) { 6 | Router.call(this); 7 | this.app = app; 8 | } 9 | 10 | NotesRouter.Prototype = function() { 11 | 12 | // URL helpers 13 | this.openNote = function(documentId) { 14 | return '#' + Router.objectToRouteString({ 15 | section: 'note', 16 | documentId: documentId 17 | }); 18 | }; 19 | }; 20 | 21 | Router.extend(NotesRouter); 22 | 23 | module.exports = NotesRouter; 24 | -------------------------------------------------------------------------------- /client/Notification.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | 5 | function Notification() { 6 | Component.apply(this, arguments); 7 | } 8 | 9 | Notification.Prototype = function() { 10 | 11 | this.render = function($$) { 12 | var el = $$('div').addClass('sc-notification se-type-' + this.props.type); 13 | el.append(this.props.message); 14 | return el; 15 | }; 16 | }; 17 | 18 | Component.extend(Notification); 19 | 20 | module.exports = Notification; -------------------------------------------------------------------------------- /client/ReadNote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Notification = require('./Notification'); 4 | var Layout = require('substance/ui/Layout'); 5 | var Button = require('substance/ui/Button'); 6 | var Icon = require('substance/ui/FontAwesomeIcon'); 7 | var NoteLoader = require('./NoteLoader'); 8 | var NoteReader = require('./NoteReader'); 9 | var RequestEditAccess = require('./RequestEditAccess'); 10 | 11 | function NoteSection() { 12 | NoteLoader.apply(this, arguments); 13 | 14 | this.handleActions({ 15 | 'closeModal': this._closeModal 16 | }); 17 | } 18 | 19 | NoteSection.Prototype = function() { 20 | 21 | this._requestLogin = function() { 22 | console.log('authenticating now'); 23 | this.extendState({ 24 | requestLogin: true 25 | }); 26 | }; 27 | 28 | this.render = function($$) { 29 | var userSession = this.props.userSession; 30 | var el = $$('div').addClass('sc-read-note'); 31 | 32 | var layout = $$(Layout, { 33 | width: 'large' 34 | }); 35 | 36 | // Display top-level errors. E.g. when a doc could not be loaded 37 | // we will display the notification on top level 38 | if (this.state.error) { 39 | layout.append($$(Notification, { 40 | type: 'error', 41 | message: this.state.error.message 42 | })); 43 | } else if (this.state.session) { 44 | if (!userSession && !this.props.mobile) { 45 | layout.append( 46 | $$(Layout, { 47 | textAlign: 'center', 48 | noPadding: true 49 | }).append( 50 | $$(Button).addClass('se-new-note-button').append( 51 | $$(Icon, {icon: 'fa-pencil'}), 52 | ' Edit' 53 | ).on('click', this._requestLogin) 54 | ) 55 | ); 56 | } 57 | 58 | layout.append( 59 | $$(NoteReader, { 60 | mobile: this.props.mobile, 61 | noteInfo: this.state.noteInfo, 62 | documentSession: this.state.session 63 | }).ref('noteReader') 64 | ); 65 | } 66 | 67 | if (this.state.requestLogin) { 68 | el.append($$(RequestEditAccess, { 69 | documentId: this.getDocumentId() 70 | })); 71 | } 72 | 73 | el.append(layout); 74 | return el; 75 | }; 76 | 77 | this._closeModal = function() { 78 | this.extendState({ 79 | requestLogin: undefined 80 | }); 81 | }; 82 | }; 83 | 84 | NoteLoader.extend(NoteSection); 85 | 86 | module.exports = NoteSection; -------------------------------------------------------------------------------- /client/RequestEditAccess.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Layout = require('substance/ui/Layout'); 4 | var Component = require('substance/ui/Component'); 5 | 6 | var Button = require('substance/ui/Button'); 7 | // var Icon = require('substance/ui/FontAwesomeIcon'); 8 | 9 | var Modal = require('substance/ui/Modal'); 10 | var RequestLogin = require('./RequestLogin'); 11 | 12 | function RequestEditAccess() { 13 | Component.apply(this, arguments); 14 | 15 | this.handleActions({ 16 | 'loginRequested': this._loginRequested 17 | }); 18 | } 19 | 20 | RequestEditAccess.Prototype = function() { 21 | 22 | this._requestLogin = function() { 23 | console.log('authenticating now'); 24 | this.extendState({ 25 | requestLogin: true 26 | }); 27 | }; 28 | 29 | this.render = function($$) { 30 | var el = $$('div').addClass('sc-request-edit-access'); 31 | 32 | if (this.state.loginRequested) { 33 | el.append( 34 | $$(Modal, { 35 | width: 'medium' 36 | }).append( 37 | $$(Layout, {textAlign: 'center'}).append( 38 | $$('p').append('We sent you an email with a link that gives you edit access.'), 39 | $$(Button).append( 40 | 'Continue Reading' 41 | ) 42 | .on('click', this.send.bind(this, 'closeModal')) 43 | ) 44 | ) 45 | ); 46 | } else { 47 | el.append( 48 | $$(Modal, { 49 | width: 'medium' 50 | }).append( 51 | $$(Layout, {textAlign: 'center'}).append( 52 | $$('p').append('Please enter your email below. You will receive a link that gives you edit access to the document.'), 53 | $$(RequestLogin, { 54 | documentId: this.props.documentId 55 | }) 56 | ) 57 | ) 58 | ); 59 | } 60 | 61 | return el; 62 | }; 63 | 64 | this._loginRequested = function() { 65 | this.setState({ 66 | loginRequested: true 67 | }); 68 | }; 69 | 70 | }; 71 | 72 | Component.extend(RequestEditAccess); 73 | 74 | module.exports = RequestEditAccess; -------------------------------------------------------------------------------- /client/RequestLogin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var Button = require('substance/ui/Button'); 5 | var Input = require('substance/ui/Input'); 6 | var Notification = require('./Notification'); 7 | 8 | function RequestLogin() { 9 | Component.apply(this, arguments); 10 | } 11 | 12 | RequestLogin.Prototype = function() { 13 | 14 | this.render = function($$) { 15 | var el = $$('div').addClass('sc-request-login'); 16 | 17 | if (this.state.requested) { 18 | el.append( 19 | $$('h1').append(this.i18n.t('sc-welcome.submitted-title')), 20 | $$('p').append(this.i18n.t('sc-welcome.submitted-instructions')) 21 | ); 22 | } else { 23 | el.append( 24 | $$('div').addClass('se-email').append( 25 | $$(Input, { 26 | type: 'text', 27 | value: this.state.email, 28 | placeholder: 'Enter your email here', 29 | centered: true 30 | }).ref('email') 31 | ) 32 | ); 33 | 34 | el.append( 35 | $$(Button, { 36 | disabled: !!this.state.loading // disable button when in loading state 37 | }).append(this.i18n.t('sc-welcome.submit')) 38 | .on('click', this._requestLoginLink) 39 | ); 40 | 41 | if (this.state.notification) { 42 | el.append($$(Notification, this.state.notification)); 43 | } 44 | } 45 | return el; 46 | }; 47 | 48 | this._requestLoginLink = function() { 49 | var email = this.refs.email.val(); 50 | var authenticationClient = this.context.authenticationClient; 51 | 52 | // Set loading state 53 | this.setState({ 54 | email: email, 55 | loading: true 56 | }); 57 | 58 | authenticationClient.requestLoginLink({ 59 | email: email, 60 | documentId: this.props.documentId 61 | }, function(err) { 62 | if (err) { 63 | this.setState({ 64 | loading: false, 65 | notification: { 66 | type: 'error', 67 | message: 'Your request could not be processed. Make sure you provided a valid email.' 68 | } 69 | }); 70 | } else { 71 | this.setState({ 72 | loading: false, 73 | requested: true 74 | }); 75 | this.send('loginRequested'); 76 | } 77 | }.bind(this)); 78 | }; 79 | }; 80 | 81 | Component.extend(RequestLogin); 82 | module.exports = RequestLogin; -------------------------------------------------------------------------------- /client/SettingsSection.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var EnterName = require('./EnterName'); 5 | 6 | function SettingsSection() { 7 | Component.apply(this, arguments); 8 | } 9 | 10 | SettingsSection.Prototype = function() { 11 | this.render = function($$) { 12 | var el = $$('div').addClass('sc-index-section').append( 13 | $$(EnterName, this.props) 14 | ); 15 | return el; 16 | }; 17 | }; 18 | 19 | Component.extend(SettingsSection); 20 | 21 | module.exports = SettingsSection; -------------------------------------------------------------------------------- /client/TodoCommand.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var SurfaceCommand = require('substance/ui/SurfaceCommand'); 4 | 5 | var TodoCommand = function(surface) { 6 | SurfaceCommand.call(this, surface); 7 | }; 8 | 9 | TodoCommand.Prototype = function() { 10 | 11 | this.getCommandState = function() { 12 | var surface = this.getSurface(); 13 | var sel = this.getSelection(); 14 | var disabled = !surface.isEnabled() || sel.isNull() || !sel.isPropertySelection(); 15 | var targetType = this.getTargetType(); 16 | 17 | return { 18 | targetType: targetType, 19 | active: targetType !== 'todo', 20 | disabled: disabled 21 | }; 22 | }; 23 | 24 | // Execute command and trigger transformations 25 | this.execute = function() { 26 | var sel = this.getSelection(); 27 | if (!sel.isPropertySelection()) return; 28 | var surface = this.getSurface(); 29 | var targetType = this.getTargetType(); 30 | 31 | if (targetType) { 32 | // A Surface transaction performs a sequence of document operations 33 | // and also considers the active selection. 34 | surface.transaction(function(tx, args) { 35 | args.data = { 36 | type: targetType 37 | }; 38 | return surface.switchType(tx, args); 39 | }); 40 | return {status: 'ok'}; 41 | } 42 | }; 43 | 44 | this.getTargetType = function() { 45 | var sel = this.getSelection(); 46 | if (sel.isNull() || !sel.isPropertySelection()) return null; 47 | var doc = this.getDocument(); 48 | var path = sel.getPath(); 49 | var node = doc.get(path[0]); 50 | // HACK: We should make sure the getCommandState is not called for 51 | // an invalid selection. 52 | if (!node) return 'paragraph'; 53 | var nodeType = node.type; 54 | 55 | if (nodeType === 'todo') { 56 | return 'paragraph'; 57 | } else { 58 | return 'todo'; 59 | } 60 | }; 61 | }; 62 | 63 | SurfaceCommand.extend(TodoCommand); 64 | 65 | TodoCommand.static.name = 'todo'; 66 | 67 | module.exports = TodoCommand; -------------------------------------------------------------------------------- /client/TodoComponent.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var TextProperty = require('substance/ui/TextPropertyComponent'); 5 | var Icon = require('substance/ui/FontAwesomeIcon'); 6 | 7 | // Todo Component 8 | // ----------------- 9 | // 10 | // Acts like a paragraph but displays a checkbox on the left that can be toggled. 11 | 12 | function TodoComponent() { 13 | Component.apply(this, arguments); 14 | } 15 | 16 | TodoComponent.Prototype = function() { 17 | 18 | // Listen to updates of the 'done' property and trigger a rerender if changed 19 | this.didMount = function() { 20 | var node = this.props.node; 21 | node.on('done:changed', this.rerender, this); 22 | }; 23 | 24 | // Unbind event handlers 25 | this.dispose = function() { 26 | var node = this.props.node; 27 | node.off(this); 28 | }; 29 | 30 | this.render = function($$) { 31 | // Checkbox defining wheter a todo is done or not. We don't want the cursor 32 | // to move inside this area,so we set contenteditable to false 33 | var checkbox = $$('span').addClass('se-done').attr({contenteditable: false}).append( 34 | $$(Icon, {icon: this.props.node.done ? "fa-check-square-o" : "fa-square-o"}) 35 | ); 36 | checkbox.on('mousedown', this.toggleDone); 37 | 38 | var el = $$('div') 39 | .addClass("sc-todo") 40 | .attr("data-id", this.props.node.id) 41 | .append([ 42 | checkbox, 43 | // TextProperty is used to render annotated content. 44 | // It takes a doc and a path to a text property as an input. 45 | $$(TextProperty, { 46 | doc: this.props.doc, 47 | path: [this.props.node.id, "content"] 48 | }) 49 | ]); 50 | 51 | if (this.props.node.done) { 52 | el.addClass('sm-done'); 53 | } 54 | return el; 55 | }; 56 | 57 | this.toggleDone = function(e) { 58 | e.preventDefault(); 59 | var node = this.props.node; 60 | var surface = this.context.surface; 61 | 62 | // A Surface transaction performs a sequence of document operations 63 | // and also considers the active selection. 64 | surface.transaction(function(tx) { 65 | tx.set([node.id, "done"], !node.done); 66 | }); 67 | }; 68 | 69 | }; 70 | 71 | Component.extend(TodoComponent); 72 | 73 | module.exports = TodoComponent; 74 | -------------------------------------------------------------------------------- /client/TodoTool.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var SurfaceTool = require('substance/ui/SurfaceTool'); 4 | 5 | function TodoTool() { 6 | TodoTool.super.apply(this, arguments); 7 | } 8 | 9 | SurfaceTool.extend(TodoTool); 10 | 11 | TodoTool.static.name = 'todo'; 12 | TodoTool.static.command = 'todo'; 13 | 14 | module.exports = TodoTool; 15 | -------------------------------------------------------------------------------- /client/Welcome.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Component = require('substance/ui/Component'); 4 | var Layout = require('substance/ui/Layout'); 5 | var RequestLogin = require('./RequestLogin'); 6 | 7 | function Welcome() { 8 | Component.apply(this, arguments); 9 | 10 | this.handleActions({ 11 | 'loginRequested': this._loginRequested 12 | }); 13 | } 14 | 15 | Welcome.Prototype = function() { 16 | 17 | this.render = function($$) { 18 | var el = $$('div').addClass('sc-welcome'); 19 | 20 | // Topbar with branding 21 | el.append( 22 | $$('div').addClass('se-topbar').html('') 23 | ); 24 | 25 | var layout = $$(Layout, { 26 | width: 'medium', 27 | textAlign: 'center' 28 | }); 29 | 30 | if (this.state.requested) { 31 | layout.append( 32 | $$('h1').append(this.i18n.t('sc-welcome.submitted-title')), 33 | $$('p').append(this.i18n.t('sc-welcome.submitted-instructions')) 34 | ); 35 | } else { 36 | layout.append( 37 | $$('h1').append( 38 | this.i18n.t('sc-welcome.title'), 39 | $$('span').addClass('se-cursor') 40 | ), 41 | $$('p').append(this.i18n.t('sc-welcome.about')), 42 | $$('h2').append(this.i18n.t('sc-welcome.no-passwords')), 43 | $$('p').append(this.i18n.t('sc-welcome.howto-login')), 44 | $$('p').append(this.i18n.t('sc-welcome.enter-email')) 45 | ); 46 | 47 | layout.append( 48 | $$(RequestLogin) 49 | ); 50 | } 51 | 52 | el.append(layout); 53 | return el; 54 | }; 55 | 56 | this._loginRequested = function() { 57 | this.setState({requested: true}); 58 | }; 59 | }; 60 | 61 | Component.extend(Welcome); 62 | 63 | module.exports = Welcome; -------------------------------------------------------------------------------- /config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "server": { 3 | "port": 5000, 4 | "host": "localhost", 5 | "wsUrl": "ws://localhost:5000", 6 | "appUrl": "http://localhost:5000" 7 | }, 8 | "app": { 9 | "port": 5000, 10 | "host": "localhost" 11 | }, 12 | "mail": { 13 | "sender": "Substance Notes ✍ ", 14 | "mailgun": { 15 | "user": "", 16 | "pass": "" 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /create-upload-folder.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var dir = './uploads'; 3 | if (!fs.existsSync(dir)){ 4 | fs.mkdirSync(dir); 5 | } 6 | -------------------------------------------------------------------------------- /data/defaultSeed.js: -------------------------------------------------------------------------------- 1 | // App seed 2 | var defaultSeed = { 3 | users: { 4 | }, 5 | sessions: { 6 | }, 7 | documents: { 8 | }, 9 | changes: { 10 | } 11 | }; 12 | 13 | module.exports = defaultSeed; -------------------------------------------------------------------------------- /data/devSeed.js: -------------------------------------------------------------------------------- 1 | var exampleNote = require('../model/exampleNote'); 2 | 3 | var testUserChange = exampleNote.createChangeset().map(function(c) { 4 | c.info = { 5 | userId: 'testuser', 6 | createdAt: new Date() 7 | }; 8 | return c; 9 | })[0]; 10 | 11 | var testUser2Change = exampleNote.createChangeset().map(function(c) { 12 | c.info = { 13 | userId: 'testuser2', 14 | createdAt: new Date() 15 | }; 16 | return c; 17 | })[0]; 18 | 19 | // App seed 20 | var devSeed = { 21 | users: { 22 | 'testuser': { 23 | userId: 'testuser', 24 | name: 'Test User', 25 | loginKey: '1234', 26 | email: 'test@example.com' 27 | }, 28 | 'testuser2': { 29 | userId: 'testuser2', 30 | name: 'Test User 2', 31 | loginKey: '12345', 32 | email: 'test2@example.com' 33 | }, 34 | 'testuser3': { 35 | userId: 'testuser3', 36 | name: '', 37 | loginKey: '123456', 38 | email: 'test3@example.com' 39 | } 40 | }, 41 | sessions: { 42 | }, 43 | documents: { 44 | 'note-1': { 45 | documentId: 'note-1', 46 | schemaName: 'substance-note', 47 | schemaVersion: '1.0.0', 48 | version: 1, 49 | info: { 50 | userId: 'testuser', 51 | title: exampleNote.createArticle().get(['meta', 'title']), 52 | updatedAt: 1458663125909 53 | } 54 | }, 55 | 'note-2': { 56 | documentId: 'note-2', 57 | schemaName: 'substance-note', 58 | schemaVersion: '1.0.0', 59 | version: 1, 60 | info: { 61 | userId: 'testuser', 62 | title: exampleNote.createArticle().get(['meta', 'title']), 63 | updatedAt: 1458663225909, 64 | updatedBy: 'testuser2' 65 | } 66 | }, 67 | 'note-3': { 68 | documentId: 'note-3', 69 | schemaName: 'substance-note', 70 | schemaVersion: '1.0.0', 71 | version: 1, 72 | info: { 73 | userId: 'testuser', 74 | title: exampleNote.createArticle().get(['meta', 'title']), 75 | updatedAt: 1458663325909 76 | } 77 | }, 78 | 'note-4': { 79 | documentId: 'note-4', 80 | schemaName: 'substance-note', 81 | schemaVersion: '1.0.0', 82 | version: 1, 83 | info: { 84 | userId: 'testuser2', 85 | title: exampleNote.createArticle().get(['meta', 'title']), 86 | updatedAt: 1458662325909, 87 | updatedBy: 'testuser2' 88 | } 89 | }, 90 | 'note-5': { 91 | documentId: 'note-5', 92 | schemaName: 'substance-note', 93 | schemaVersion: '1.0.0', 94 | version: 1, 95 | info: { 96 | userId: 'testuser2', 97 | title: exampleNote.createArticle().get(['meta', 'title']), 98 | updatedAt: 1458662125909 99 | } 100 | }, 101 | 'note-6': { 102 | documentId: 'note-6', 103 | schemaName: 'substance-note', 104 | schemaVersion: '1.0.0', 105 | version: 1, 106 | info: { 107 | userId: 'testuser2', 108 | title: exampleNote.createArticle().get(['meta', 'title']), 109 | updatedAt: 1458662725909 110 | } 111 | } 112 | }, 113 | changes: { 114 | 'note-1': [testUserChange], 115 | 'note-2': [testUser2Change], 116 | 'note-3': [testUser2Change], 117 | 'note-4': [testUserChange], 118 | 'note-5': [testUserChange], 119 | 'note-6': [testUserChange] 120 | } 121 | }; 122 | 123 | module.exports = devSeed; -------------------------------------------------------------------------------- /db/migrations/20151222003441_changes.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex, Promise) { 2 | return knex.schema.createTable('changes', function(table) { 3 | table.string('documentId'); 4 | table.integer('version'); 5 | table.string('data'); 6 | table.integer('createdAt'); 7 | table.string('userId'); 8 | table.primary(['documentId', 'version']); 9 | 10 | // Index so we can query by documentId and or userId (needed to extract collaborators) 11 | table.index(['documentId']); 12 | table.index(['userId']); 13 | table.index(['documentId', 'userId']); 14 | }); 15 | }; 16 | 17 | exports.down = function(knex, Promise) { 18 | return knex.schema.dropTable('changes'); 19 | }; -------------------------------------------------------------------------------- /db/migrations/20160221114137_users.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex, Promise) { 2 | return knex.schema.createTable('users', function(table) { 3 | table.string('userId').primary(); 4 | table.string('name'); 5 | table.string('email').unique(); 6 | table.integer('createdAt'); 7 | table.string('loginKey').unique().index(); 8 | }); 9 | }; 10 | 11 | exports.down = function(knex, Promise) { 12 | return knex.schema.dropTable('users'); 13 | }; -------------------------------------------------------------------------------- /db/migrations/20160221114148_documents.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex, Promise) { 2 | return knex.schema.createTable('documents', function(table) { 3 | table.string('documentId').unique().index(); 4 | table.string('schemaName'); 5 | table.string('schemaVersion'); 6 | table.string('info'); 7 | table.integer('version'); 8 | table.string('title'); 9 | table.integer('updatedAt'); 10 | table.string('updatedBy'); 11 | table.string('userId'); 12 | }); 13 | }; 14 | 15 | exports.down = function(knex, Promise) { 16 | return knex.schema.dropTable('documents'); 17 | }; -------------------------------------------------------------------------------- /db/migrations/20160221114148_sessions.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex, Promise) { 2 | return knex.schema.createTable('sessions', function(table) { 3 | table.string('sessionToken').primary(); 4 | table.integer('timestamp'); 5 | table.integer('userId'); 6 | }); 7 | }; 8 | 9 | exports.down = function(knex, Promise) { 10 | return knex.schema.dropTable('sessions'); 11 | }; -------------------------------------------------------------------------------- /db/migrations/20160221114148_snapshots.js: -------------------------------------------------------------------------------- 1 | exports.up = function(knex, Promise) { 2 | return knex.schema.createTable('snapshots', function(table) { 3 | table.string('documentId'); 4 | table.integer('version'); 5 | table.string('data'); 6 | table.integer('createdAt'); 7 | table.primary(['documentId', 'version']); 8 | }); 9 | }; 10 | 11 | exports.down = function(knex, Promise) { 12 | return knex.schema.dropTable('snapshots'); 13 | }; -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substance/notes/399bc4d1abc008ed7a614487ca48a333af7da530/favicon.ico -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var jshint = require('gulp-jshint'); 3 | var replace = require('gulp-replace'); 4 | var each = require('lodash/each'); 5 | var sass = require('gulp-sass'); 6 | var browserify = require('browserify'); 7 | var uglify = require('gulp-uglify'); 8 | var through2 = require('through2'); 9 | var rename = require('gulp-rename'); 10 | var config = require('config'); 11 | 12 | /** 13 | * Bundle 14 | */ 15 | 16 | gulp.task('assets', function () { 17 | // Index HTML 18 | var metaTags = []; 19 | each(config.app, function(val, key) { 20 | metaTags.push(''); 21 | }); 22 | gulp.src('./index.html') 23 | .pipe(replace('', metaTags.join(''))) 24 | .pipe(gulp.dest('./dist')); 25 | 26 | // Assets 27 | gulp.src('./styles/assets/**/*') 28 | .pipe(gulp.dest('./dist/assets')); 29 | 30 | // Font Awesome 31 | gulp.src('node_modules/font-awesome/fonts/*') 32 | .pipe(gulp.dest('./dist/fonts')); 33 | }); 34 | 35 | gulp.task('sass', function() { 36 | gulp.src('./app.scss') 37 | .pipe(sass().on('error', sass.logError)) 38 | .pipe(rename('app.css')) 39 | .pipe(gulp.dest('./dist')); 40 | }); 41 | 42 | gulp.task('browserify', function() { 43 | gulp.src('./app.js') 44 | .pipe(through2.obj(function (file, enc, next) { 45 | browserify(file.path) 46 | .bundle(function (err, res) { 47 | if (err) { return next(err); } 48 | file.contents = res; 49 | next(null, file); 50 | }); 51 | })) 52 | .on('error', function (error) { 53 | console.log(error.stack); 54 | this.emit('end'); 55 | }) 56 | .pipe(uglify().on('error', function(err){console.log(err); })) 57 | .pipe(gulp.dest('./dist')); 58 | }); 59 | 60 | gulp.task('bundle', ['assets', 'sass', 'browserify']); 61 | 62 | /** 63 | * Tests 64 | */ 65 | 66 | gulp.task('lint', function() { 67 | return gulp.src([ 68 | './model/**/*.js', 69 | './client/**/*.js', 70 | './server/**/*.js', 71 | ]).pipe(jshint()) 72 | .pipe(jshint.reporter('default')) 73 | .pipe(jshint.reporter("fail")); 74 | }); 75 | 76 | gulp.task('test:server', ['lint'], function() { 77 | // requiring instead of doing 'node test/run.js' 78 | require('./test/run'); 79 | }); 80 | 81 | gulp.task('test', ['lint', 'test:server']); 82 | 83 | gulp.task('default', ['bundle']); -------------------------------------------------------------------------------- /i18n/en/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /* 3 | Welcome page 4 | */ 5 | 'sc-welcome.brand': '
Substance Notes
', 6 | 'sc-welcome.title': 'Substance Notes', 7 | 'sc-welcome.about': 'Substance Notes is an open source collaborative notes editing tool. You can write documents, comments, upload images and have your friends join in when you want their input. Joining the writing session is easy, all your friends need is your Note’s URL, and they can write away!', 8 | 'sc-welcome.no-passwords': 'No passwords, just email', 9 | 'sc-welcome.howto-login': 'A Substance Notes account just needs an e-mail address, so we can send you a link to your dashboard with a one-time login key. There’s no need to remember a password — as long as you have access to your e-mail, you’ll have access to your Notes.', 10 | 'sc-welcome.enter-email': 'Interested? Enter your email below and get access to the beta.', 11 | 'sc-welcome.submit': 'Request login', 12 | 'sc-welcome.submitted-title': 'Thank you!', 13 | 'sc-welcome.submitted-instructions': 'We have sent an email to you containing a secret link to access your dashboard.', 14 | 'todo': 'Todo', 15 | 'comment': 'Comment', 16 | 'comment.content': 'Comment', 17 | 'meta.title': 'Title' 18 | }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Substance Notepad 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /knexfile.js: -------------------------------------------------------------------------------- 1 | // Update with your config settings. 2 | 3 | module.exports = { 4 | development: { 5 | client: 'sqlite3', 6 | connection: { 7 | filename: './db/development.hub.sqlite3' 8 | }, 9 | migrations: { 10 | directory: './db/migrations', 11 | tableName: 'knex_migrations' 12 | }, 13 | seeds: { 14 | directory: './db/seeds' 15 | } 16 | }, 17 | 18 | test: { 19 | client: 'sqlite3', 20 | connection: { 21 | filename: './db/test.hub.sqlite3' 22 | }, 23 | migrations: { 24 | directory: './db/migrations', 25 | tableName: 'knex_migrations' 26 | } 27 | }, 28 | 29 | production: { 30 | client: 'sqlite3', 31 | connection: { 32 | filename: './db/production.hub.sqlite3' 33 | }, 34 | migrations: { 35 | directory: './db/migrations', 36 | tableName: 'knex_migrations' 37 | } 38 | } 39 | }; -------------------------------------------------------------------------------- /model/Note.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Document = require('substance/model/Document'); 4 | var noteSchema = require('./noteSchema'); 5 | 6 | /** 7 | Note article class 8 | */ 9 | var Note = function(schema) { 10 | Document.call(this, schema || noteSchema); 11 | 12 | // Holds a sequence of node ids 13 | this.create({ 14 | type: 'container', 15 | id: 'body', 16 | nodes: [] 17 | }); 18 | }; 19 | 20 | Note.Prototype = function() { 21 | this.getDocumentMeta = function() { 22 | return this.get('meta'); 23 | }; 24 | }; 25 | 26 | Document.extend(Note); 27 | Note.schema = noteSchema; 28 | 29 | module.exports = Note; 30 | -------------------------------------------------------------------------------- /model/exampleNote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createDocumentFactory = require('substance/model/createDocumentFactory'); 4 | var Note = require('./Note'); 5 | 6 | module.exports = createDocumentFactory(Note, function(tx) { 7 | var body = tx.get('body'); 8 | 9 | tx.create({ 10 | id: 'meta', 11 | type: 'meta', 12 | title: 'Welcome to Substance Notes' 13 | }); 14 | 15 | tx.create({ 16 | id: 'p1', 17 | type: 'paragraph', 18 | content: 'Substance is a JavaScript library for web-based content editing. Build simple text editors or full-featured publishing systems. Substance provides you building blocks for your very custom editor.' 19 | }); 20 | body.show('p1'); 21 | 22 | tx.create({ 23 | id: 'm1', 24 | type: 'mark', 25 | path: ['p1', 'content'], 26 | startOffset: 0, 27 | endOffset: 9 28 | }); 29 | 30 | tx.create({ 31 | id: 't1', 32 | type: 'todo', 33 | done: false, 34 | content: 'Water the plants' 35 | }); 36 | body.show('t1'); 37 | 38 | tx.create({ 39 | id: 't2', 40 | type: 'todo', 41 | done: true, 42 | content: 'Fix bug' 43 | }); 44 | body.show('t2'); 45 | 46 | tx.create({ 47 | id: 't3', 48 | type: 'todo', 49 | done: true, 50 | content: 'Do taxes' 51 | }); 52 | body.show('t3'); 53 | }); 54 | -------------------------------------------------------------------------------- /model/exampleNote.old.js: -------------------------------------------------------------------------------- 1 | var Note = require('./Note'); 2 | var doc = new Note(); 3 | var body = doc.get('body'); 4 | 5 | doc.create({ 6 | id: 'p1', 7 | type: 'paragraph', 8 | content: 'Substance is a JavaScript library for web-based content editing. Build simple text editors or full-featured publishing systems. Substance provides you building blocks for your very custom editor.' 9 | }); 10 | body.show('p1'); 11 | 12 | doc.create({ 13 | id: 'm1', 14 | type: 'mark', 15 | path: ['p1', 'content'], 16 | startOffset: 0, 17 | endOffset: 9 18 | }); 19 | 20 | doc.create({ 21 | id: 't1', 22 | type: 'todo', 23 | done: false, 24 | content: 'Water the plants' 25 | }); 26 | body.show('t1'); 27 | 28 | doc.create({ 29 | id: 't2', 30 | type: 'todo', 31 | done: true, 32 | content: 'Fix bug' 33 | }); 34 | body.show('t2'); 35 | 36 | doc.create({ 37 | id: 't3', 38 | type: 'todo', 39 | done: true, 40 | content: 'Do taxes' 41 | }); 42 | body.show('t3'); 43 | 44 | module.exports = doc; -------------------------------------------------------------------------------- /model/exampleNoteChangeset.js: -------------------------------------------------------------------------------- 1 | var Note = require('./Note'); 2 | var DocumentSession = require('substance/model/DocumentSession'); 3 | 4 | /* 5 | Returns an example changeset describing the contents of the document 6 | */ 7 | function exampleNoteChangeset() { 8 | 9 | var doc = new Note(); 10 | var session = new DocumentSession(doc); 11 | 12 | var change = session.transaction(function(tx) { 13 | var body = tx.get('body'); 14 | 15 | tx.create({ 16 | id: 'p1', 17 | type: 'paragraph', 18 | content: 'Substance is a JavaScript library for web-based content editing. Build simple text editors or full-featured publishing systems. Substance provides you building blocks for your very custom editor.' 19 | }); 20 | body.show('p1'); 21 | 22 | tx.create({ 23 | id: 'm1', 24 | type: 'mark', 25 | path: ['p1', 'content'], 26 | startOffset: 0, 27 | endOffset: 9 28 | }); 29 | 30 | tx.create({ 31 | id: 't1', 32 | type: 'todo', 33 | done: false, 34 | content: 'Water the plants' 35 | }); 36 | body.show('t1'); 37 | 38 | tx.create({ 39 | id: 't2', 40 | type: 'todo', 41 | done: true, 42 | content: 'Fix bug' 43 | }); 44 | body.show('t2'); 45 | 46 | tx.create({ 47 | id: 't3', 48 | type: 'todo', 49 | done: true, 50 | content: 'Do taxes' 51 | }); 52 | body.show('t3'); 53 | }); 54 | 55 | return [change.toJSON()]; 56 | } 57 | 58 | module.exports = exampleNoteChangeset; 59 | -------------------------------------------------------------------------------- /model/newNote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var createDocumentFactory = require('substance/model/createDocumentFactory'); 4 | var Note = require('./Note'); 5 | 6 | module.exports = createDocumentFactory(Note, function(tx) { 7 | var body = tx.get('body'); 8 | 9 | tx.create({ 10 | id: 'meta', 11 | type: 'meta', 12 | title: 'Untitled Note' 13 | }); 14 | 15 | tx.create({ 16 | id: 'p1', 17 | type: 'paragraph', 18 | content: 'Write your note here.' 19 | }); 20 | body.show('p1'); 21 | }); 22 | -------------------------------------------------------------------------------- /model/noteSchema.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var DocumentSchema = require('substance/model/DocumentSchema'); 4 | var DocumentNode = require('substance/model/DocumentNode'); 5 | var TextBlock = require('substance/model/TextBlock'); 6 | var PropertyAnnotation = require('substance/model/PropertyAnnotation'); 7 | 8 | /** 9 | Simple mark for highlighting text in a note 10 | */ 11 | 12 | function Mark() { 13 | Mark.super.apply(this, arguments); 14 | } 15 | PropertyAnnotation.extend(Mark); 16 | Mark.static.name = 'mark'; 17 | 18 | /** 19 | Todo item represented with annotated text (content) and boolean flag (done). 20 | */ 21 | function Todo() { 22 | Todo.super.apply(this, arguments); 23 | } 24 | 25 | TextBlock.extend(Todo); 26 | Todo.static.name = 'todo'; 27 | 28 | Todo.static.defineSchema({ 29 | content: 'text', 30 | done: { type: 'bool', default: false } 31 | }); 32 | 33 | /** 34 | Comment item for inline commenting 35 | */ 36 | 37 | function Comment() { 38 | Comment.super.apply(this, arguments); 39 | } 40 | 41 | TextBlock.extend(Comment); 42 | Comment.static.name = 'comment'; 43 | 44 | Comment.static.defineSchema({ 45 | content: 'text', 46 | author: { type: 'string', default: '' }, 47 | createdAt: { type: 'string', default: new Date().toISOString() } 48 | }); 49 | 50 | /** 51 | Meta 52 | */ 53 | 54 | function Meta() { 55 | Meta.super.apply(this, arguments); 56 | } 57 | 58 | DocumentNode.extend(Meta); 59 | 60 | Meta.static.name = 'meta'; 61 | 62 | Meta.static.defineSchema({ 63 | title: { type: 'string', default: 'Untitled'} 64 | }); 65 | 66 | /** 67 | Schema instance 68 | */ 69 | var schema = new DocumentSchema('substance-note', '1.0.0'); 70 | schema.getDefaultTextType = function() { 71 | return 'paragraph'; 72 | }; 73 | 74 | schema.addNodes([ 75 | require('substance/packages/paragraph/Paragraph'), 76 | require('substance/packages/heading/Heading'), 77 | require('substance/packages/codeblock/Codeblock'), 78 | require('substance/packages/blockquote/Blockquote'), 79 | require('substance/packages/image/Image'), 80 | require('substance/packages/emphasis/Emphasis'), 81 | require('substance/packages/strong/Strong'), 82 | require('substance/packages/code/Code'), 83 | require('substance/packages/link/Link'), 84 | Comment, 85 | Todo, 86 | Mark, 87 | Meta 88 | ]); 89 | 90 | module.exports = schema; 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "substance-notepad", 3 | "repository": "substance/notepad", 4 | "description": "Real-time notes editing", 5 | "license": "MIT", 6 | "dependencies": { 7 | "body-parser": "1.15.0", 8 | "config": "^1.19.0", 9 | "express": "4.13.1", 10 | "jquery": "*", 11 | "knex": "0.10.0", 12 | "lodash": "4.2.1", 13 | "moment": "2.12.0", 14 | "multer": "1.1.0", 15 | "nodemailer": "^2.2.1", 16 | "sqlite3": "3.1.1", 17 | "substance": "substance/substance#444bbca731546bb481aa28acefb90011bfecab71", 18 | "ws": "^1.0.1" 19 | }, 20 | "devDependencies": { 21 | "async": "1.5.2", 22 | "browserify": "^10.2.4", 23 | "colors": "1.1.2", 24 | "font-awesome": "4.5.0", 25 | "glob": "6.0.1", 26 | "gulp": "^3.8.11", 27 | "gulp-jshint": "2.0.0", 28 | "gulp-rename": "^1.2.2", 29 | "gulp-replace": "^0.5.4", 30 | "gulp-sass": "^2.0.3", 31 | "gulp-uglify": "^1.2.0", 32 | "jshint": "2.8.0", 33 | "mocha": "2.4.5", 34 | "node-sass": "3.x", 35 | "qunitjs": "1.20.0", 36 | "sinon": "1.17.2", 37 | "through2": "^2.0.0" 38 | }, 39 | "browserify": {}, 40 | "scripts": { 41 | "bundle": "gulp bundle", 42 | "postinstall": "node ./create-upload-folder", 43 | "seed": "node seed", 44 | "test": "NODE_ENV=test gulp test" 45 | }, 46 | "version": "1.0.0-beta" 47 | } 48 | -------------------------------------------------------------------------------- /queries.sql: -------------------------------------------------------------------------------- 1 | # BASICS 2 | # ------------------- 3 | 4 | # Comma separate multiple string values 5 | SELECT GROUP_CONCAT(name) FROM (SELECT name FROM users); 6 | 7 | 8 | # Get collaborators for a certain document 9 | SELECT GROUP_CONCAT(name) FROM 10 | (SELECT u.name FROM changes c INNER JOIN users u ON (c.userId = u.userId) WHERE documentId = 'note-99') 11 | 12 | # Get all documents (title + collaborators) 13 | 14 | SELECT 15 | title, 16 | -- collaborators 17 | (SELECT GROUP_CONCAT(name) FROM (SELECT u.name FROM changes c INNER JOIN users u ON (c.userId = u.userId) WHERE c.documentId = d.documentId)) 18 | FROM documents d; 19 | 20 | 21 | # Dashboard queries 22 | # ------------------- 23 | 24 | # My documents (title, creator name, collaborators) 25 | 26 | SELECT 27 | d.title, 28 | u.name as creator, 29 | -- collaborators (all except creator) 30 | (SELECT GROUP_CONCAT(name) FROM 31 | (SELECT u.name FROM changes c INNER JOIN users u ON (c.userId = u.userId) 32 | WHERE c.documentId = d.documentId AND c.userId != d.userId)) AS collaborators, 33 | d.updatedAt, 34 | (SELECT name FROM users WHERE userId=d.updatedBy) AS updatedBy 35 | FROM documents d INNER JOIN users u ON (d.userId = u.userId) 36 | WHERE d.userId = 'testuser2' 37 | 38 | 39 | # Collaborated docs of testuser (someone else created it but 'testuser' made change) 40 | 41 | SELECT 42 | d.title, 43 | u.name as creator, 44 | -- collaborators (all except creator) 45 | (SELECT GROUP_CONCAT(name) FROM 46 | (SELECT u.name FROM changes c INNER JOIN users u ON (c.userId = u.userId) 47 | WHERE c.documentId = d.documentId AND c.userId != d.userId)) AS collaborators, 48 | d.updatedAt, 49 | (SELECT name FROM users WHERE userId=d.updatedBy) AS updatedBy 50 | FROM documents d INNER JOIN users u ON (d.userId = u.userId) 51 | WHERE d.documentId IN (SELECT documentId FROM changes WHERE userId = 'testuser2') AND d.userId != 'testuser2' 52 | 53 | 54 | -------------------------------------------------------------------------------- /seed.js: -------------------------------------------------------------------------------- 1 | var UserStore = require('./server/UserStore'); 2 | var SessionStore = require('./server/SessionStore'); 3 | var ChangeStore = require('./server/ChangeStore'); 4 | var DocumentStore = require('./server/DocumentStore'); 5 | var Database = require('./server/Database'); 6 | var seed = require('./data/defaultSeed'); 7 | var db = new Database(); 8 | 9 | // If dev option provided will use another seed file 10 | if (process.argv[2] == 'dev') { 11 | seed = require('./data/devSeed'); 12 | console.log('Development seeding...'); 13 | } 14 | 15 | db.reset() // Clear the database, set up the schema 16 | .then(function() { 17 | var userStore = new UserStore({ db: db }); 18 | return userStore.seed(seed.users); 19 | }).then(function() { 20 | var sessionStore = new SessionStore({ db: db }); 21 | return sessionStore.seed(seed.sessions); 22 | }).then(function() { 23 | var changeStore = new ChangeStore({db: db}); 24 | return changeStore.seed(seed.changes); 25 | }).then(function() { 26 | var documentStore = new DocumentStore({db: db}); 27 | return documentStore.seed(seed.documents); 28 | }).then(function() { 29 | console.log('Done seeding.'); 30 | db.shutdown(); 31 | }); -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | var config = require('config'); 2 | var express = require('express'); 3 | var path = require('path'); 4 | var app = express(); 5 | var server = require('substance/util/server'); 6 | var newNote = require('./model/newNote'); 7 | var CollabServer = require('substance/collab/CollabServer'); 8 | var DocumentChange = require('substance/model/DocumentChange'); 9 | var DocumentEngine = require('./server/NotesDocumentEngine'); 10 | var DocumentStore = require('./server/DocumentStore'); 11 | var ChangeStore = require('./server/ChangeStore'); 12 | var UserStore = require('./server/UserStore'); 13 | var SessionStore = require('./server/SessionStore'); 14 | var AuthenticationServer = require('./server/AuthenticationServer'); 15 | var DocumentServer = require('./server/NotesDocumentServer'); 16 | var AuthenticationEngine = require('./server/AuthenticationEngine'); 17 | var FileStore = require('./server/FileStore'); 18 | var FileServer = require('./server/FileServer'); 19 | var NotesServer = require('./server/NotesServer'); 20 | var NotesEngine = require('./server/NotesEngine'); 21 | var Database = require('./server/Database'); 22 | var bodyParser = require('body-parser'); 23 | var http = require('http'); 24 | var WebSocketServer = require('ws').Server; 25 | 26 | var db = new Database(); 27 | 28 | // Set up stores 29 | // ------------------------------- 30 | 31 | var userStore = new UserStore({ db: db }); 32 | var sessionStore = new SessionStore({ db: db }); 33 | 34 | // We use the in-memory versions for now, thus we need to seed 35 | // each time. 36 | var changeStore = new ChangeStore({db: db}); 37 | var documentStore = new DocumentStore({db: db}); 38 | 39 | var fileStore = new FileStore({destination: './uploads'}); 40 | 41 | var documentEngine = new DocumentEngine({ 42 | db: db, 43 | documentStore: documentStore, 44 | changeStore: changeStore, 45 | schemas: { 46 | 'substance-note': { 47 | name: 'substance-note', 48 | version: '1.0.0', 49 | documentFactory: newNote 50 | } 51 | } 52 | }); 53 | 54 | var authenticationEngine = new AuthenticationEngine({ 55 | userStore: userStore, 56 | sessionStore: sessionStore, 57 | emailService: null // TODO 58 | }); 59 | 60 | var notesEngine = new NotesEngine({db: db}); 61 | 62 | /* 63 | Express body-parser configureation 64 | */ 65 | app.use(bodyParser.json({limit: '3mb'})); 66 | app.use(bodyParser.urlencoded({ 67 | extended: true, 68 | limit: '3mb', 69 | parameterLimit: 3000 70 | })); 71 | 72 | /* 73 | Serve app 74 | */ 75 | var config = config.get('server'); 76 | var env = config.util.getEnv('NODE_ENV'); 77 | 78 | if(env !== 'production') { 79 | // Serve HTML, bundled JS and CSS in non-production mode 80 | server.serveHTML(app, '/', path.join(__dirname, 'index.html'), config); 81 | server.serveStyles(app, '/app.css', path.join(__dirname, 'app.scss')); 82 | server.serveJS(app, '/app.js', path.join(__dirname, 'app.js')); 83 | // Serve static files in non-production mode 84 | app.use('/assets', express.static(path.join(__dirname, 'styles/assets'))); 85 | app.use('/fonts', express.static(path.join(__dirname, 'node_modules/font-awesome/fonts'))); 86 | } else { 87 | app.use('/', express.static(path.join(__dirname, '/dist'))); 88 | } 89 | 90 | /* 91 | Serve uploads directory 92 | */ 93 | app.use('/media', express.static(path.join(__dirname, 'uploads'))); 94 | 95 | // Connect Substance 96 | // ---------------- 97 | 98 | var httpServer = http.createServer(); 99 | var wss = new WebSocketServer({ server: httpServer }); 100 | 101 | // DocumentServer 102 | // ---------------- 103 | 104 | var documentServer = new DocumentServer({ 105 | documentEngine: documentEngine, 106 | path: '/api/documents' 107 | }); 108 | documentServer.bind(app); 109 | 110 | 111 | // CollabServer 112 | // ---------------- 113 | 114 | var collabServer = new CollabServer({ 115 | // every 30s a heart beat message is sent to keep 116 | // websocket connects alive when they are inactive 117 | heartbeat: 30*1000, 118 | documentEngine: documentEngine, 119 | 120 | /* 121 | Checks for authentication based on message.sessionToken 122 | */ 123 | authenticate: function(req, cb) { 124 | var sessionToken = req.message.sessionToken; 125 | authenticationEngine.getSession(sessionToken).then(function(session) { 126 | cb(null, session); 127 | }).catch(function(err) { 128 | cb(err); 129 | }); 130 | }, 131 | 132 | /* 133 | Will store the userId along with each change. We also want to build 134 | a documentInfo object to update the document record with some data 135 | */ 136 | enhanceRequest: function(req, cb) { 137 | var message = req.message; 138 | if (message.type === 'sync') { 139 | // We fetch the document record to get the old title 140 | documentStore.getDocument(message.documentId, function(err, docRecord) { 141 | var updatedAt = new Date(); 142 | var title = docRecord.title; 143 | 144 | if (message.change) { 145 | // Update the title if necessary 146 | var change = DocumentChange.fromJSON(message.change); 147 | change.ops.forEach(function(op) { 148 | if(op.path[0] == 'meta' && op.path[1] == 'title') { 149 | title = op.diff.apply(title); 150 | } 151 | }); 152 | 153 | message.change.info = { 154 | userId: req.session.userId, 155 | updatedAt: updatedAt 156 | }; 157 | } 158 | 159 | message.collaboratorInfo = { 160 | name: req.session.user.name 161 | }; 162 | 163 | // commit and connect method take optional documentInfo argument 164 | message.documentInfo = { 165 | updatedAt: updatedAt, 166 | updatedBy: req.session.userId, 167 | title: title 168 | }; 169 | cb(null); 170 | }); 171 | } else { 172 | // Just continue for everything that is not handled 173 | cb(null); 174 | } 175 | } 176 | }); 177 | 178 | collabServer.bind(wss); 179 | 180 | // AuthenticationServer 181 | // ---------------- 182 | 183 | var authenticationServer = new AuthenticationServer({ 184 | authenticationEngine: authenticationEngine, 185 | path: '/api/auth/' 186 | }); 187 | 188 | authenticationServer.bind(app); 189 | 190 | // NotesServer 191 | // ---------------- 192 | 193 | var notesServer = new NotesServer({ 194 | notesEngine: notesEngine, 195 | path: '/api/notes' 196 | }); 197 | notesServer.bind(app); 198 | 199 | // File Server 200 | // ---------------- 201 | // 202 | // E.g. used for image uploads 203 | 204 | var fileServer = new FileServer({ 205 | store: fileStore, 206 | path: '/api/files' 207 | }); 208 | fileServer.bind(app); 209 | 210 | 211 | // Error handling 212 | // We send JSON to the client so they can display messages in the UI. 213 | 214 | /* jshint unused: false */ 215 | app.use(function(err, req, res, next) { 216 | if (res.headersSent) { 217 | return next(err); 218 | } 219 | 220 | if (err.inspect) { 221 | // This is a SubstanceError where we have detailed info 222 | console.error(err.inspect()); 223 | } else { 224 | // For all other errors, let's just print the stack trace 225 | console.error(err.stack); 226 | } 227 | 228 | res.status(500).json({ 229 | errorName: err.name, 230 | errorMessage: err.message || err.name 231 | }); 232 | }); 233 | 234 | // Delegate http requests to express app 235 | httpServer.on('request', app); 236 | 237 | // NOTE: binding to localhost means that the app is not exposed 238 | // to the www directly. 239 | // E.g. on sandbox.substance.io we have established a reverse proxy 240 | // forwarding http+ws on notepad.substance.io to localhost:5001 241 | httpServer.listen(config.port, config.host, function() { 242 | console.log('Listening on ' + httpServer.address().port); 243 | }); 244 | 245 | // Export app for requiring in test files 246 | module.exports = app; 247 | -------------------------------------------------------------------------------- /server/AuthenticationEngine.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var appConfig = require('config'); 4 | var uuid = require('substance/util/uuid'); 5 | var oo = require('substance/util/oo'); 6 | var Err = require('substance/util/Error'); 7 | var Mail = require('./Mail'); 8 | 9 | /* 10 | Implements authentication logic 11 | */ 12 | function AuthenticationEngine(config) { 13 | this.userStore = config.userStore; 14 | this.sessionStore = config.sessionStore; 15 | this.emailService = config.emailService; 16 | } 17 | 18 | AuthenticationEngine.Prototype = function() { 19 | 20 | /* 21 | Generate new loginKey for user and send email with a link 22 | */ 23 | this.requestLoginLink = function(args) { 24 | var userStore = this.userStore; 25 | return userStore.getUserByEmail(args.email) 26 | .catch(function() { 27 | // User does not exist, we create a new one 28 | return userStore.createUser({email: args.email}); 29 | }) 30 | .then(this._updateLoginKey.bind(this)) 31 | .then(function(user) { 32 | return this._sendLoginLink(user, args.documentId); 33 | }.bind(this)); 34 | }; 35 | 36 | /* 37 | Authenticate based on either sessionToken 38 | */ 39 | this.authenticate = function(loginData) { 40 | if (loginData.loginKey) { 41 | return this._authenticateWithLoginKey(loginData.loginKey); 42 | } else { 43 | return this._authenticateWithToken(loginData.sessionToken); 44 | } 45 | }; 46 | 47 | /* 48 | Get session by session token 49 | 50 | TODO: Include session expiration mechanism. If session is found but expired 51 | the session entry should be deleted here 52 | */ 53 | this.getSession = function(sessionToken) { 54 | return this.sessionStore.getSession(sessionToken).then( 55 | this._enrichSession.bind(this) 56 | ); 57 | }; 58 | 59 | this.updateUserName = function(args) { 60 | var userStore = this.userStore; 61 | return userStore.updateUser(args.userId, {name: args.name}); 62 | }; 63 | 64 | /* 65 | Generates a new login key for a given email 66 | */ 67 | this._updateLoginKey = function(user) { 68 | var userStore = this.userStore; 69 | var newLoginKey = uuid(); 70 | return userStore.updateUser(user.userId, {loginKey: newLoginKey}); 71 | }; 72 | 73 | /* 74 | Send a login link via email 75 | */ 76 | this._sendLoginLink = function(user, documentId) { 77 | var url = appConfig.get('server.appUrl'); 78 | var subject = "Your login key!"; 79 | var msg; 80 | 81 | if (documentId) { 82 | msg = "Click the following link to edit: "+url + "/#section=note,documentId=" +documentId+",loginKey=" + user.loginKey; 83 | } else { 84 | msg = "Click the following link to login: "+url + "/#loginKey=" + user.loginKey; 85 | } 86 | 87 | console.log('Message: ', msg); 88 | return Mail.sendPlain(user.email, subject, msg) 89 | .then(function(info){ 90 | console.log(info); 91 | return { 92 | loginKey: user.loginKey 93 | }; 94 | }).catch(function(err) { 95 | throw new Err('EmailError', { 96 | cause: err 97 | }); 98 | }); 99 | }; 100 | 101 | /* 102 | Creates a new session based on an existing sessionToken 103 | 104 | 1. old session gets read 105 | 2. if exists old session gets deleted 106 | 3. new session gets created for the same user 107 | 4. rich user object gets attached 108 | */ 109 | this._authenticateWithToken = function(sessionToken) { 110 | var sessionStore = this.sessionStore; 111 | var self = this; 112 | 113 | return new Promise(function(resolve, reject) { 114 | sessionStore.getSession(sessionToken).then(function(session) { 115 | return self._enrichSession(session); 116 | }).then(function(richSession) { 117 | resolve(richSession); 118 | }).catch(function(err) { 119 | reject(new Err('AuthenticationError', { 120 | cause: err 121 | })); 122 | }); 123 | }); 124 | }; 125 | 126 | /* 127 | Authenicate based on login key 128 | */ 129 | this._authenticateWithLoginKey = function(loginKey) { 130 | var sessionStore = this.sessionStore; 131 | var userStore = this.userStore; 132 | var self = this; 133 | 134 | return new Promise(function(resolve, reject) { 135 | userStore.getUserByLoginKey(loginKey).then(function(user) { 136 | return sessionStore.createSession({userId: user.userId}); 137 | }).then(function(newSession) { 138 | return self._enrichSession(newSession); 139 | }).then(function(richSession) { 140 | resolve(richSession); 141 | }).catch(function(err) { 142 | reject(new Err('AuthenticationError', { 143 | cause: err 144 | })); 145 | }); 146 | }); 147 | }; 148 | 149 | /* 150 | Attached a full user object to the session record 151 | */ 152 | this._enrichSession = function(session) { 153 | var userStore = this.userStore; 154 | return new Promise(function(resolve, reject) { 155 | userStore.getUser(session.userId).then(function(user) { 156 | session.user = user; 157 | resolve(session); 158 | }).catch(function(err) { 159 | reject(new Err('AuthenticationError', { 160 | cause: err 161 | })); 162 | }); 163 | }); 164 | }; 165 | }; 166 | 167 | oo.initClass(AuthenticationEngine); 168 | module.exports = AuthenticationEngine; 169 | -------------------------------------------------------------------------------- /server/AuthenticationServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | 5 | /* 6 | Implements a simple AuthenticationServer we may want to 7 | reuse for other Substance projects. 8 | */ 9 | function AuthenticationServer(config) { 10 | this.engine = config.authenticationEngine; 11 | this.path = config.path; 12 | } 13 | 14 | AuthenticationServer.Prototype = function() { 15 | 16 | /* 17 | Attach this server to an express instance 18 | 19 | @param {String} mountPath must be something like '/api/auth/' 20 | */ 21 | this.bind = function(app) { 22 | app.post(this.path + 'loginlink', this._requestLoginLink.bind(this)); 23 | app.post(this.path + 'authenticate', this._authenticate.bind(this)); 24 | app.post(this.path + 'changename', this._changename.bind(this)); 25 | }; 26 | 27 | /* 28 | Generate new loginKey for user and send email with a link 29 | */ 30 | this._requestLoginLink = function(req, res, next) { 31 | var args = req.body; // has email and docId (optional) which should be included in the login url. 32 | 33 | this.engine.requestLoginLink(args).then(function(result) { 34 | console.log('this.engine.requestLoginLink result', result); 35 | res.json({status: 'ok'}); 36 | }).catch(function(err) { 37 | return next(err); 38 | }); 39 | }; 40 | 41 | /* 42 | Authenticate based on either sessionToken 43 | */ 44 | this._authenticate = function(req, res, next) { 45 | var loginData = req.body; 46 | 47 | this.engine.authenticate(loginData).then(function(session) { 48 | res.json(session); 49 | }).catch(function(err) { 50 | return next(err); 51 | }); 52 | }; 53 | 54 | 55 | /* 56 | Change user name 57 | */ 58 | this._changename = function(req, res, next) { 59 | var args = req.body; 60 | 61 | this.engine.updateUserName(args).then(function() { 62 | res.json({status: 'ok'}); 63 | }).catch(function(err) { 64 | return next(err); 65 | }); 66 | }; 67 | }; 68 | 69 | oo.initClass(AuthenticationServer); 70 | module.exports = AuthenticationServer; -------------------------------------------------------------------------------- /server/ChangeStore.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var oo = require('substance/util/oo'); 4 | var _ = require('substance/util/helpers'); 5 | var has = require('lodash/has'); 6 | var Err = require('substance/util/Error'); 7 | 8 | /* 9 | Implements the Substance DocumentStore API. 10 | */ 11 | function ChangeStore(config) { 12 | this.config = config; 13 | this.db = config.db.connection; 14 | } 15 | 16 | ChangeStore.Prototype = function() { 17 | 18 | // Changes API 19 | // ----------- 20 | 21 | /* 22 | Add a change to a document 23 | 24 | @param {Object} args arguments 25 | @param {String} args.documentId document id 26 | @param {Object} args.change JSON object 27 | @param {Function} cb callback 28 | */ 29 | this.addChange = function(args, cb) { 30 | var self = this; 31 | 32 | if(!has(args, 'documentId')) { 33 | return cb(new Err('ChangeStore.CreateError', { 34 | message: 'documentId is mandatory' 35 | })); 36 | } 37 | 38 | var userId = null; 39 | if(args.change.info) { 40 | userId = args.change.info.userId; 41 | } 42 | 43 | self.getVersion(args.documentId, function(err, headVersion) { 44 | if (err) return cb(new Err('ChangeStore.GetVersionError', { 45 | cause: err 46 | })); 47 | var version = headVersion + 1; 48 | var record = { 49 | documentId: args.documentId, 50 | version: version, 51 | data: JSON.stringify(args.change), 52 | createdAt: args.createdAt || new Date(), 53 | userId: userId 54 | }; 55 | 56 | self.db.table('changes').insert(record) 57 | .asCallback(function(err) { 58 | if (err) return cb(new Err('ChangeStore.CreateError', { 59 | cause: err 60 | })); 61 | cb(null, version); 62 | }); 63 | }); 64 | }; 65 | 66 | /* 67 | Add a change to a document 68 | 69 | Promise based version 70 | 71 | @param {Object} args arguments 72 | @param {String} args.documentId document id 73 | @param {Object} args.change JSON object 74 | */ 75 | this._addChange = function(args) { 76 | var self = this; 77 | var version; 78 | 79 | var userId = null; 80 | if(args.change.info) { 81 | userId = args.change.info.userId; 82 | } 83 | 84 | return self._getVersion(args.documentId) 85 | .then(function(headVersion) { 86 | version = headVersion + 1; 87 | var record = { 88 | documentId: args.documentId, 89 | version: version, 90 | data: JSON.stringify(args.change), 91 | createdAt: args.createdAt || new Date(), 92 | userId: userId 93 | }; 94 | return self.db.table('changes').insert(record); 95 | }) 96 | .then(function() { 97 | return version; 98 | }); 99 | }; 100 | 101 | /* 102 | Get changes from the DB 103 | 104 | @param {Object} args arguments 105 | @param {String} args.documentId document id 106 | @param {String} args.sinceVersion changes since version (0 = all changes, 1 all except first change) 107 | @param {Function} cb callback 108 | */ 109 | this.getChanges = function(args, cb) { 110 | var self = this; 111 | 112 | if(args.sinceVersion < 0) { 113 | return cb(new Err('ChangeStore.ReadError', { 114 | message: 'sinceVersion should be grater or equal then 0' 115 | })); 116 | } 117 | 118 | if(args.toVersion < 0) { 119 | return cb(new Err('ChangeStore.ReadError', { 120 | message: 'toVersion should be grater then 0' 121 | })); 122 | } 123 | 124 | if(args.sinceVersion >= args.toVersion) { 125 | return cb(new Err('ChangeStore.ReadError', { 126 | message: 'toVersion should be greater then sinceVersion' 127 | })); 128 | } 129 | 130 | if(!has(args, 'sinceVersion')) args.sinceVersion = 0; 131 | 132 | var query = self.db('changes') 133 | .select('data') 134 | .where('documentId', args.documentId) 135 | .andWhere('version', '>', args.sinceVersion) 136 | .orderBy('version', 'asc'); 137 | 138 | if(args.toVersion) query.andWhere('version', '<=', args.toVersion); 139 | 140 | query.asCallback(function(err, changes) { 141 | if (err) return cb(new Err('ChangeStore.ReadError', { 142 | cause: err 143 | })); 144 | changes = _.map(changes, function(c) {return JSON.parse(c.data); }); 145 | self.getVersion(args.documentId, function(err, headVersion) { 146 | if (err) return cb(new Err('ChangeStore.GetVersionError', { 147 | cause: err 148 | })); 149 | var res = { 150 | version: headVersion, 151 | changes: changes 152 | }; 153 | return cb(null, res); 154 | }); 155 | }); 156 | }; 157 | 158 | /* 159 | Remove all changes of a document 160 | 161 | @param {String} id document id 162 | @param {Function} cb callback 163 | */ 164 | this.deleteChanges = function(id, cb) { 165 | var query = this.db('changes') 166 | .where('documentId', id) 167 | .del(); 168 | 169 | query.asCallback(function(err, deletedCount) { 170 | if (err) return cb(new Err('ChangeStore.DeleteError', { 171 | cause: err 172 | })); 173 | return cb(null, deletedCount); 174 | }); 175 | }; 176 | 177 | /* 178 | Get the version number for a document 179 | 180 | @param {String} id document id 181 | @param {Function} cb callback 182 | */ 183 | this.getVersion = function(id, cb) { 184 | // HINT: version = count of changes 185 | // 0 changes: version = 0 186 | // 1 change: version = 1 187 | var query = this.db('changes') 188 | .where('documentId', id) 189 | .count(); 190 | 191 | query.asCallback(function(err, count) { 192 | if (err) return cb(new Err('ChangeStore.GetVersionError', { 193 | cause: err 194 | })); 195 | var result = count[0]['count(*)']; 196 | return cb(null, result); 197 | }); 198 | }; 199 | 200 | /* 201 | Get the version number for a document 202 | 203 | Promise based version 204 | 205 | @param {String} id document id 206 | */ 207 | this._getVersion = function(id) { 208 | var query = this.db('changes') 209 | .where('documentId', id) 210 | .count(); 211 | 212 | return query.then(function(count) { 213 | var result = count[0]['count(*)']; 214 | return result; 215 | }); 216 | }; 217 | 218 | /* 219 | Resets the database and loads a given seed object 220 | 221 | Be careful with running this in production 222 | 223 | @param {Object} seed JSON object 224 | @param {Function} cb callback 225 | */ 226 | 227 | this.seed = function(changesets) { 228 | var self = this; 229 | var changes = []; 230 | _.each(changesets, function(set, docId) { 231 | _.each(set, function(change) { 232 | var args = { 233 | documentId: docId, 234 | change: change 235 | }; 236 | changes.push(args); 237 | }); 238 | }); 239 | 240 | // Seed changes in sequence 241 | return changes.reduce(function(promise, change) { 242 | return promise.then(function() { 243 | return self._addChange(change); 244 | }); 245 | }, Promise.resolve()); 246 | }; 247 | }; 248 | 249 | oo.initClass(ChangeStore); 250 | 251 | module.exports = ChangeStore; -------------------------------------------------------------------------------- /server/Database.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | var Knex = require('knex'); 5 | var knexConfig = require('../knexfile'); 6 | var env = process.env.NODE_ENV || 'development'; 7 | 8 | /* 9 | Implements Database Conection API. 10 | */ 11 | function Database() { 12 | this.connect(); 13 | } 14 | 15 | Database.Prototype = function() { 16 | 17 | /* 18 | Connect to the db 19 | */ 20 | this.connect = function() { 21 | this.config = knexConfig[env]; 22 | if (!this.config) { 23 | throw new Error('Could not find config for environment', env); 24 | } 25 | this.connection = new Knex(this.config); 26 | }; 27 | 28 | /* 29 | Disconnect from the db and shut down 30 | */ 31 | this.shutdown = function() { 32 | this.connection.destroy(); 33 | }; 34 | 35 | /* 36 | Wipe DB and run lagtest migartion 37 | 38 | @param {Function} cb callback 39 | */ 40 | this.reset = function() { 41 | var self = this; 42 | 43 | return self.connection.schema 44 | .dropTableIfExists('changes') 45 | .dropTableIfExists('documents') 46 | .dropTableIfExists('sessions') 47 | .dropTableIfExists('snapshots') 48 | .dropTableIfExists('users') 49 | // We should drop migrations table 50 | // to rerun the same migration again 51 | .dropTableIfExists('knex_migrations') 52 | .then(function() { 53 | return self.connection.migrate.latest(self.config); 54 | }).catch(function(error) { 55 | console.error(error); 56 | }); 57 | }; 58 | 59 | }; 60 | 61 | oo.initClass(Database); 62 | 63 | module.exports = Database; 64 | -------------------------------------------------------------------------------- /server/DocumentStore.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var oo = require('substance/util/oo'); 4 | var _ = require('substance/util/helpers'); 5 | var Err = require('substance/util/Error'); 6 | var uuid = require('substance/util/uuid'); 7 | 8 | /* 9 | Implements the Substance DocumentStore API. 10 | */ 11 | function DocumentStore(config) { 12 | this.config = config; 13 | this.db = config.db.connection; 14 | } 15 | 16 | DocumentStore.Prototype = function() { 17 | 18 | /* 19 | Remove a document record from the db 20 | 21 | @param {String} documentId document id 22 | @param {Function} cb callback 23 | */ 24 | this.deleteDocument = function(documentId, cb) { 25 | var query = this.db('documents') 26 | .where('documentId', documentId) 27 | .del(); 28 | 29 | this.getDocument(documentId, function(err, doc) { 30 | if (err) { 31 | return cb(new Err('DocumentStore.DeleteError', { 32 | cause: err 33 | })); 34 | } 35 | 36 | query.asCallback(function(err) { 37 | if (err) { 38 | return cb(new Err('DocumentStore.DeleteError', { 39 | cause: err 40 | })); 41 | } 42 | cb(null, doc); 43 | }); 44 | }); 45 | }; 46 | 47 | // Documents API helpers 48 | // --------------------- 49 | 50 | /* 51 | Internal method to create a document record 52 | */ 53 | this.createDocument = function(props, cb) { 54 | 55 | if (!props.documentId) { 56 | // We generate a documentId ourselves 57 | props.documentId = uuid(); 58 | } 59 | 60 | var self = this; 61 | if(props.info) { 62 | if(props.info.title) props.title = props.info.title; 63 | if(props.info.userId) { 64 | props.userId = props.info.userId; 65 | props.updatedBy = props.info.userId; 66 | } 67 | if(props.info.updatedAt) props.updatedAt = props.info.updatedAt; 68 | props.info = JSON.stringify(props.info); 69 | } 70 | this.db.table('documents').insert(props) 71 | .asCallback(function(err) { 72 | if (err) { 73 | return cb(new Err('DocumentStore.CreateError', { 74 | cause: err 75 | })); 76 | } 77 | self.getDocument(props.documentId, cb); 78 | }); 79 | }; 80 | 81 | /* 82 | Promise version 83 | */ 84 | this._createDocument = function(props) { 85 | if(props.info) { 86 | if(props.info.title) props.title = props.info.title; 87 | if(props.info.userId) props.userId = props.info.userId; 88 | if(props.info.updatedAt) props.updatedAt = props.info.updatedAt; 89 | // Let's keep updatedBy here for seeding 90 | if(props.info.updatedBy) { 91 | props.updatedBy = props.info.updatedBy; 92 | } else if (props.info.userId){ 93 | props.updatedBy = props.info.userId; 94 | } 95 | props.info = JSON.stringify(props.info); 96 | } 97 | return this.db.table('documents').insert(props); 98 | }; 99 | 100 | this.documentExists = function(documentId, cb) { 101 | var query = this.db('documents') 102 | .where('documentId', documentId) 103 | .limit(1); 104 | 105 | query.asCallback(function(err, doc) { 106 | if (err) { 107 | return cb(new Err('DocumentStore.ReadError', { 108 | cause: err, 109 | info: 'Happened within documentExists.' 110 | })); 111 | } 112 | cb(null, doc.length > 0); 113 | }); 114 | }; 115 | 116 | /* 117 | Internal method to get a document 118 | */ 119 | this.getDocument = function(documentId, cb) { 120 | var query = this.db('documents') 121 | .where('documentId', documentId); 122 | 123 | query.asCallback(function(err, doc) { 124 | if (err) { 125 | return cb(new Err('DocumentStore.ReadError', { 126 | cause: err 127 | })); 128 | } 129 | doc = doc[0]; 130 | if (!doc) { 131 | return cb(new Err('DocumentStore.ReadError', { 132 | message: 'No document found for documentId ' + documentId, 133 | })); 134 | } 135 | if(doc.info) { 136 | doc.info = JSON.parse(doc.info); 137 | } 138 | cb(null, doc); 139 | }); 140 | }; 141 | 142 | /* 143 | Update a document record 144 | */ 145 | this.updateDocument = function(documentId, props, cb) { 146 | var self = this; 147 | if(props.info) { 148 | if(props.info.title) props.title = props.info.title; 149 | if(props.info.userId) props.userId = props.info.userId; 150 | if(props.info.updatedAt) props.updatedAt = props.info.updatedAt; 151 | if(props.info.updatedBy) props.updatedBy = props.info.updatedBy; 152 | props.info = JSON.stringify(props.info); 153 | } 154 | this.documentExists(documentId, function(err, exists) { 155 | if (err) { 156 | return cb(new Err('DocumentStore.UpdateError', { 157 | cause: err 158 | })); 159 | } 160 | if (!exists) { 161 | return cb(new Err('DocumentStore.UpdateError', { 162 | message: 'Document ' + documentId + ' does not exists' 163 | })); 164 | } 165 | self.db.table('documents').where('documentId', documentId).update(props) 166 | .asCallback(function(err) { 167 | if (err) { 168 | return cb(new Err('DocumentStore.UpdateError', { 169 | cause: err 170 | })); 171 | } 172 | self.getDocument(documentId, cb); 173 | }); 174 | }); 175 | }; 176 | 177 | /* 178 | List available documents 179 | @param {Object} filters filters 180 | @param {Function} cb callback 181 | */ 182 | this.listDocuments = function(filters, cb) { 183 | var query = this.db('documents') 184 | .where(filters); 185 | 186 | query.asCallback(cb); 187 | }; 188 | 189 | /* 190 | Resets the database and loads a given seed object 191 | 192 | Be careful with running this in production 193 | 194 | @param {Object} seed JSON object 195 | @param {Function} cb callback 196 | */ 197 | 198 | this.seed = function(seed) { 199 | var self = this; 200 | var actions = _.map(seed, self._createDocument.bind(self)); 201 | 202 | return Promise.all(actions); 203 | }; 204 | }; 205 | 206 | oo.initClass(DocumentStore); 207 | 208 | module.exports = DocumentStore; -------------------------------------------------------------------------------- /server/FileServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | var Err = require('substance/util/Error'); 5 | 6 | /* 7 | FileServer module. Can be bound to an express instance 8 | */ 9 | function FileServer(config) { 10 | this.path = config.path; 11 | this.store = config.store; 12 | } 13 | 14 | FileServer.Prototype = function() { 15 | 16 | /* 17 | Attach this server to an express instance 18 | */ 19 | this.bind = function(app) { 20 | app.post(this.path, this._uploadFile.bind(this)); 21 | }; 22 | 23 | /* 24 | Upload a file 25 | */ 26 | this._uploadFile = function(req, res, next) { 27 | var self = this; 28 | var uploader = this.store.getFileUploader('files'); 29 | uploader(req, res, function (err) { 30 | if (err) return next(new Err('FileStore.UploadError', { 31 | cause: err 32 | })); 33 | res.json({name: self.store.getFileName(req)}); 34 | }); 35 | }; 36 | }; 37 | 38 | oo.initClass(FileServer); 39 | module.exports = FileServer; -------------------------------------------------------------------------------- /server/FileStore.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | var uuid = require('substance/util/uuid'); 5 | var multer = require('multer'); 6 | 7 | /* 8 | Implements Substance Store API. 9 | */ 10 | function FileStore(config) { 11 | this.storage = multer.diskStorage({ 12 | destination: function (req, file, cb) { 13 | cb(null, config.destination); 14 | }, 15 | filename: function (req, file, cb) { 16 | var extension = file.originalname.split('.').pop(); 17 | cb(null, uuid() + '.' + extension); 18 | } 19 | }); 20 | this.uploader = multer({ 21 | storage: this.storage 22 | }); 23 | } 24 | 25 | FileStore.Prototype = function() { 26 | 27 | /* 28 | Returns middleware for file uploading 29 | */ 30 | this.getFileUploader = function(fieldname) { 31 | return this.uploader.single(fieldname); 32 | }; 33 | 34 | /* 35 | Get name of stored file 36 | */ 37 | this.getFileName = function(req) { 38 | return req.file.filename; 39 | }; 40 | }; 41 | 42 | oo.initClass(FileStore); 43 | 44 | module.exports = FileStore; 45 | -------------------------------------------------------------------------------- /server/Mail.js: -------------------------------------------------------------------------------- 1 | var config = require('config'); 2 | var nodemailer = require('nodemailer'); 3 | 4 | var Mail = {}; 5 | 6 | var sender = config.get('mail.sender'); 7 | var mailgunCredentials = { 8 | user: config.get('mail.mailgun.user'), 9 | pass: config.get('mail.mailgun.pass') 10 | }; 11 | 12 | Mail.sendPlain = function(to, subject, content) { 13 | 14 | var transporter = nodemailer.createTransport({ 15 | service: 'Mailgun', 16 | auth: mailgunCredentials 17 | }); 18 | 19 | var message = { 20 | from: sender, 21 | to: to, 22 | subject: subject, 23 | text: content 24 | }; 25 | 26 | return transporter.sendMail(message); 27 | }; 28 | 29 | module.exports = Mail; -------------------------------------------------------------------------------- /server/NotesDocumentEngine.js: -------------------------------------------------------------------------------- 1 | var DocumentEngine = require('substance/collab/DocumentEngine'); 2 | var Err = require('substance/util/Error'); 3 | 4 | /* 5 | DocumentEngine 6 | */ 7 | function NotesDocumentEngine(config) { 8 | NotesDocumentEngine.super.apply(this, arguments); 9 | this.db = config.db.connection; 10 | } 11 | 12 | NotesDocumentEngine.Prototype = function() { 13 | 14 | var _super = NotesDocumentEngine.super.prototype; 15 | 16 | this.createDocument = function(args, cb) { 17 | var schemaConfig = this.schemas[args.schemaName]; 18 | if (!schemaConfig) { 19 | return cb(new Err('SchemaNotFoundError', { 20 | message: 'Schema not found for ' + args.schemaName 21 | })); 22 | } 23 | var docFactory = schemaConfig.documentFactory; 24 | var doc = docFactory.createArticle(); 25 | args.info.updatedAt = new Date(); 26 | args.info.title = doc.get(['meta', 'title']); 27 | _super.createDocument.call(this, args, cb); 28 | }; 29 | 30 | this.getDocument = function(args, cb) { 31 | var self = this; 32 | // SQL query powered 33 | this.queryDocumentMetaData(args.documentId, function(err, docEntry) { 34 | if (err) { 35 | return cb(new Err('NotesDocumentEngine.ReadDocumentMetadataError', { 36 | cause: err 37 | })); 38 | } 39 | self.snapshotEngine.getSnapshot(args, function(err, snapshot) { 40 | if (err) { 41 | return cb(new Err('NotesDocumentEngine.ReadSnapshotError', { 42 | cause: err 43 | })); 44 | } 45 | docEntry.data = snapshot.data; 46 | cb(null, docEntry); 47 | }); 48 | }); 49 | }; 50 | 51 | this.queryDocumentMetaData = function(documentId, cb) { 52 | var query = "SELECT d.documentId, d.updatedAt, d.version, d.schemaName, d.schemaVersion, (SELECT GROUP_CONCAT(name) FROM (SELECT DISTINCT u.name FROM changes c INNER JOIN users u ON (c.userId = u.userId) WHERE c.documentId = d.documentId AND c.userId != d.userId)) AS collaborators, (SELECT createdAt FROM changes c WHERE c.documentId=d.documentId ORDER BY createdAt ASC LIMIT 1) AS createdAt, u.name AS author, f.name AS updatedBy FROM documents d JOIN users u ON(u.userId=d.userId) JOIN users f ON(f.userId=d.updatedBy) WHERE d.documentId = ?"; 53 | 54 | this.db.raw(query, [documentId]).asCallback(function(err, doc) { 55 | if (err) { 56 | return cb(new Err('NotesDocumentEngine.ReadDocumentMetaDataError', { 57 | cause: err 58 | })); 59 | } 60 | doc = doc[0]; 61 | if (!doc) { 62 | return cb(new Err('NotesDocumentEngine.ReadDocumentMetaDataError', { 63 | message: 'No document found for documentId ' + documentId, 64 | })); 65 | } 66 | if(!doc.collaborators) { 67 | doc.collaborators = []; 68 | } else { 69 | doc.collaborators = doc.collaborators.split(','); 70 | } 71 | cb(null, doc); 72 | }); 73 | }; 74 | }; 75 | 76 | DocumentEngine.extend(NotesDocumentEngine); 77 | 78 | module.exports = NotesDocumentEngine; -------------------------------------------------------------------------------- /server/NotesDocumentServer.js: -------------------------------------------------------------------------------- 1 | var DocumentServer = require('substance/collab/DocumentServer'); 2 | 3 | /* 4 | DocumentServer module. Can be bound to an express instance 5 | */ 6 | function NotesDocumentServer() { 7 | NotesDocumentServer.super.apply(this, arguments); 8 | } 9 | 10 | NotesDocumentServer.Prototype = function() { 11 | // var _super = NotesDocumentServer.super.prototype; 12 | 13 | // this.bind = function(app) { 14 | // _super.bind.apply(this, arguments); 15 | 16 | // // Add notes specific routes 17 | // }; 18 | 19 | }; 20 | 21 | DocumentServer.extend(NotesDocumentServer); 22 | 23 | module.exports = NotesDocumentServer; -------------------------------------------------------------------------------- /server/NotesEngine.js: -------------------------------------------------------------------------------- 1 | var oo = require('substance/util/oo'); 2 | var Err = require('substance/util/Error'); 3 | 4 | /* 5 | Implements the NotesEngine API. 6 | */ 7 | function NotesEngine(config) { 8 | this.config = config; 9 | this.db = config.db.connection; 10 | } 11 | 12 | NotesEngine.Prototype = function() { 13 | 14 | this._enhanceDocs = function(docs) { 15 | docs.forEach(function(doc) { 16 | if (!doc.collaborators) { 17 | doc.collaborators = []; 18 | } else { 19 | // Turn comma separated values into array 20 | doc.collaborators = doc.collaborators.split(','); 21 | } 22 | if (!doc.creator) { 23 | doc.creator = 'Anonymous'; 24 | } 25 | if (!doc.updatedBy) { 26 | doc.updatedBy = 'Anonymous'; 27 | } 28 | }); 29 | return docs; 30 | }; 31 | 32 | this.getUserDashboard = function(userId, cb) { 33 | 34 | var userDocsQuery = "SELECT d.title as title, d.documentId as documentId, u.name as creator, (SELECT GROUP_CONCAT(name) FROM (SELECT DISTINCT u.name FROM changes c INNER JOIN users u ON (c.userId = u.userId) WHERE c.documentId = d.documentId AND c.userId != d.userId)) AS collaborators, d.updatedAt as updatedAt, (SELECT name FROM users WHERE userId=d.updatedBy) AS updatedBy FROM documents d INNER JOIN users u ON (d.userId = u.userId) WHERE d.userId = :userId"; 35 | var collabDocsQuery = "SELECT d.title as title, d.documentId as documentId, u.name as creator, (SELECT GROUP_CONCAT(name) FROM (SELECT DISTINCT u.name FROM changes c INNER JOIN users u ON (c.userId = u.userId) WHERE c.documentId = d.documentId AND c.userId != d.userId)) AS collaborators, d.updatedAt as updatedAt, (SELECT name FROM users WHERE userId=d.updatedBy) AS updatedBy FROM documents d INNER JOIN users u ON (d.userId = u.userId) WHERE d.documentId IN (SELECT documentId FROM changes WHERE userId = :userId) AND d.userId != :userId ORDER BY d.updatedAt DESC"; 36 | 37 | // Combine the two queries 38 | var query = [userDocsQuery, 'UNION', collabDocsQuery].join(' '); 39 | 40 | this.db.raw(query, {userId: userId}).asCallback(function(err, docs) { 41 | if (err) { 42 | return cb(new Err('ReadError', { 43 | cause: err 44 | })); 45 | } 46 | cb(null, this._enhanceDocs(docs)); 47 | }.bind(this)); 48 | }; 49 | }; 50 | 51 | oo.initClass(NotesEngine); 52 | 53 | module.exports = NotesEngine; -------------------------------------------------------------------------------- /server/NotesServer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | 5 | /* 6 | DocumentServer module. Can be bound to an express instance 7 | */ 8 | function NotesServer(config) { 9 | this.engine = config.notesEngine; 10 | this.path = config.path; 11 | } 12 | 13 | NotesServer.Prototype = function() { 14 | 15 | /* 16 | Attach this server to an express instance 17 | */ 18 | this.bind = function(app) { 19 | app.get(this.path + '/dashboard/user/:id', this._getUserDashboard.bind(this)); 20 | }; 21 | 22 | /* 23 | Get a dashboard documents 24 | */ 25 | this._getUserDashboard = function(req, res, next) { 26 | var userId = req.params.id; 27 | this.engine.getUserDashboard(userId, function(err, docs) { 28 | if (err) return next(err); 29 | res.json(docs); 30 | }); 31 | }; 32 | }; 33 | 34 | oo.initClass(NotesServer); 35 | module.exports = NotesServer; -------------------------------------------------------------------------------- /server/SessionStore.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | var map = require('lodash/map'); 5 | var uuid = require('substance/util/uuid'); 6 | var Err = require('substance/util/Error'); 7 | 8 | /* 9 | A simple SQL Session Store implementation 10 | */ 11 | function SessionStore(config) { 12 | this.db = config.db.connection; 13 | } 14 | 15 | SessionStore.Prototype = function() { 16 | 17 | /* 18 | Create a session record for a given user 19 | 20 | @param {String} userId user id 21 | */ 22 | this.createSession = function(session) { 23 | var newSession = { 24 | sessionToken: session.sessionToken || uuid(), 25 | timestamp: Date.now(), 26 | userId: session.userId 27 | }; 28 | 29 | return this.db.table('sessions').insert(newSession) 30 | .then(function() { 31 | return newSession; 32 | }); 33 | }; 34 | 35 | /* 36 | Get session entry based on a session token 37 | 38 | @param {String} sessionToken session token 39 | */ 40 | this.getSession = function(sessionToken) { 41 | var query = this.db('sessions') 42 | .where('sessionToken', sessionToken); 43 | 44 | return query 45 | .then(function(session) { 46 | if (session.length === 0) { 47 | throw new Err('SessionStore.ReadError', { 48 | message: 'No session found for token ' + sessionToken 49 | }); 50 | } 51 | session = session[0]; 52 | return session; 53 | }); 54 | }; 55 | 56 | /* 57 | Remove session entry based with a given session token 58 | 59 | @param {String} sessionToken session token 60 | */ 61 | this.deleteSession = function(sessionToken) { 62 | var self = this; 63 | var deletedSession; 64 | 65 | return this.getSession(sessionToken) 66 | .then(function(session) { 67 | deletedSession = session; 68 | return self.db('sessions') 69 | .where('sessionToken', sessionToken) 70 | .del(); 71 | }).then(function() { 72 | return deletedSession; 73 | }).catch(function(err) { 74 | throw new Err('SessionStore.DeleteError', { 75 | message: 'Could not delete session ' + sessionToken, 76 | cause: err 77 | }); 78 | }); 79 | }; 80 | 81 | /* 82 | Check if session exists 83 | */ 84 | this._sessionExists = function(sessionToken) { 85 | var query = this.db('sessions') 86 | .where('sessionToken', sessionToken) 87 | .limit(1); 88 | 89 | return query.then(function(session) { 90 | return session.length > 0; 91 | }); 92 | }; 93 | 94 | /* 95 | Resets the database and loads a given seed object 96 | 97 | Be careful with running this in production 98 | 99 | @param {Object} seed JSON object 100 | */ 101 | this.seed = function(seed) { 102 | var self = this; 103 | var actions = map(seed, self.createSession.bind(self)); 104 | 105 | return Promise.all(actions); 106 | }; 107 | }; 108 | 109 | oo.initClass(SessionStore); 110 | 111 | module.exports = SessionStore; 112 | -------------------------------------------------------------------------------- /server/SnapshotStore.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | var _ = require('substance/util/helpers'); 5 | var Err = require('substance/util/Error'); 6 | 7 | /* 8 | Implements Substance SnapshotStore API. 9 | */ 10 | function SnapshotStore(config) { 11 | this.config = config; 12 | this.db = config.db.connection; 13 | } 14 | 15 | SnapshotStore.Prototype = function() { 16 | 17 | 18 | /* 19 | Get Snapshot by documentId and version. If no version is provided 20 | the highest version available is returned 21 | 22 | @return {Object} snapshot record 23 | */ 24 | this.getSnapshot = function(args, cb) { 25 | if (!args || !args.documentId) { 26 | return cb(new Err('InvalidArgumentsError', { 27 | message: 'args require a documentId' 28 | })); 29 | } 30 | 31 | var query = this.db('snapshots') 32 | .where('documentId', args.documentId) 33 | .orderBy('version', 'desc') 34 | .limit(1); 35 | 36 | if(args.version && args.findClosest) { 37 | query.andWhere('version', '<=', args.version); 38 | } else if (args.version) { 39 | query.andWhere('version', args.version); 40 | } 41 | 42 | 43 | query.asCallback(function(err, snapshot) { 44 | if (err) return cb(new Err('SnapshotStore.ReadError', { 45 | cause: err 46 | })); 47 | snapshot = snapshot[0]; 48 | if (snapshot) snapshot.data = JSON.parse(snapshot.data); 49 | cb(null, snapshot); 50 | }); 51 | }; 52 | 53 | /* 54 | Stores a snapshot for a given documentId and version. 55 | 56 | Please note that an existing snapshot will be overwritten. 57 | */ 58 | this.saveSnapshot = function(args, cb) { 59 | var record = { 60 | documentId: args.documentId, 61 | version: args.version, 62 | data: JSON.stringify(args.data), 63 | createdAt: args.createdAt || new Date() 64 | }; 65 | this.db.table('snapshots').insert(record) 66 | .asCallback(function(err) { 67 | if (err) return cb(new Err('SnapshotStore.CreateError', { 68 | cause: err 69 | })); 70 | cb(null, record); 71 | }); 72 | }; 73 | 74 | // Promise based version 75 | this._saveSnapshot = function(args) { 76 | var record = { 77 | documentId: args.documentId, 78 | version: args.version, 79 | data: JSON.stringify(args.data), 80 | createdAt: args.createdAt || new Date() 81 | }; 82 | return this.db.table('snapshots').insert(record); 83 | }; 84 | 85 | /* 86 | Removes a snapshot for a given documentId + version 87 | */ 88 | this.deleteSnaphot = function(documentId, version, cb) { 89 | var query = this.db('snapshots') 90 | .where('documentId', documentId) 91 | .andWhere('version', version) 92 | .del(); 93 | 94 | var args = { 95 | documentId: documentId, 96 | version: version 97 | }; 98 | this.getSnapshot(args, function(err, snapshot){ 99 | if (err) return cb(new Err('SnapshotStore.ReadError', { 100 | cause: err 101 | })); 102 | query.asCallback(function(err) { 103 | if (err) return cb(new Err('SnapshotStore.DeleteError', { 104 | cause: err 105 | })); 106 | return cb(null, snapshot); 107 | }); 108 | }); 109 | }; 110 | 111 | /* 112 | Deletes all snapshots for a given documentId 113 | */ 114 | this.deleteSnapshotsForDocument = function(documentId, cb) { 115 | var query = this.db('snapshots') 116 | .where('documentId', documentId) 117 | .del(); 118 | 119 | query.asCallback(function(err, deleteCount) { 120 | if (err) return cb(new Err('SnapshotStore.DeleteForDocumentError', { 121 | cause: err 122 | })); 123 | return cb(null, deleteCount); 124 | }); 125 | }; 126 | 127 | /* 128 | Returns true if a snapshot exists for a certain version 129 | */ 130 | this.snapshotExists = function(documentId, version, cb) { 131 | var query = this.db('snapshots') 132 | .where('documentId', documentId) 133 | .andWhere('version', version) 134 | .limit(1); 135 | 136 | query.asCallback(function(err, snapshot) { 137 | if (err) { 138 | return cb(new Err('SnapshotStore.ReadError', { 139 | cause: err, 140 | info: 'Happened within snapshotExists.' 141 | })); 142 | } 143 | cb(null, snapshot.length > 0); 144 | }); 145 | }; 146 | 147 | /* 148 | Seeds the database 149 | */ 150 | this.seed = function(seed) { 151 | 152 | var self = this; 153 | var snapshots = []; 154 | _.each(seed, function(versions) { 155 | _.each(versions, function(version) { 156 | snapshots.push(version); 157 | }); 158 | }); 159 | 160 | // Seed changes in sequence 161 | return snapshots.reduce(function(promise, snapshot) { 162 | return promise.then(function() { 163 | return self._saveSnapshot(snapshot); 164 | }); 165 | }, Promise.resolve()); 166 | 167 | }; 168 | 169 | }; 170 | 171 | 172 | oo.initClass(SnapshotStore); 173 | module.exports = SnapshotStore; 174 | -------------------------------------------------------------------------------- /server/UserStore.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var oo = require('substance/util/oo'); 4 | var map = require('lodash/map'); 5 | var uuid = require('substance/util/uuid'); 6 | var Err = require('substance/util/Error'); 7 | /* 8 | Implements Substance Store API. This is just a stub and is used for 9 | testing. 10 | */ 11 | function UserStore(config) { 12 | this.db = config.db.connection; 13 | } 14 | 15 | UserStore.Prototype = function() { 16 | 17 | /* 18 | Create a new user record (aka signup) 19 | 20 | @param {Object} userData JSON object 21 | */ 22 | this.createUser = function(userData) { 23 | var self = this; 24 | 25 | // Generate a userId if not provided 26 | if (!userData.userId) { 27 | userData.userId = uuid(); 28 | } 29 | 30 | if (userData.name === undefined) { 31 | userData.name = ''; 32 | } 33 | 34 | return this.userExists(userData.userId) 35 | .then(function(exists) { 36 | if (exists) { 37 | throw new Err('UserStore.CreateError', { 38 | message: 'User already exists.' 39 | }); 40 | } 41 | return self._createUser(userData); 42 | }); 43 | }; 44 | 45 | /* 46 | Get user record for a given userId 47 | 48 | @param {String} userId user id 49 | */ 50 | this.getUser = function(userId) { 51 | var query = this.db('users') 52 | .where('userId', userId); 53 | 54 | return query.then(function(rows) { 55 | if (rows.length === 0) { 56 | throw new Error('No user found for userId ' + userId); 57 | } 58 | return rows[0]; 59 | }); 60 | }; 61 | 62 | /* 63 | Update a user record with given props 64 | 65 | @param {String} userId user id 66 | @param {Object} props properties to update 67 | */ 68 | this.updateUser = function(userId, props) { 69 | var self = this; 70 | var update = this.db('users') 71 | .where('userId', userId) 72 | .update(props); 73 | 74 | return update.then(function() { 75 | return self.getUser(userId); 76 | }).catch(function(err) { 77 | throw new Err('UserStore.UpdateError', { 78 | // Pass the original error as a cause, so caller can inspect it 79 | cause: err 80 | }); 81 | }); 82 | }; 83 | 84 | /* 85 | Remove a user from the db 86 | 87 | @param {String} userId user id 88 | */ 89 | this.deleteUser = function(userId) { 90 | var deletedUser; 91 | var del = this.db('users') 92 | .where('userId', userId) 93 | .del(); 94 | 95 | 96 | // We fetch the user record before we delete it 97 | return this.getUser(userId).then(function(user) { 98 | deletedUser = user; 99 | return del; 100 | }).then(function() { 101 | return deletedUser; 102 | }).catch(function(err) { 103 | throw new Err('UserStore.DeleteError', { 104 | // Pass the original error as a cause, so caller can inspect it 105 | cause: err 106 | }); 107 | }); 108 | }; 109 | 110 | /* 111 | Get user record for a given loginKey 112 | 113 | @param {String} loginKey login key 114 | */ 115 | this.getUserByLoginKey = function(loginKey) { 116 | var query = this.db('users') 117 | .where('loginKey', loginKey); 118 | 119 | return query 120 | .then(function(user) { 121 | if (user.length === 0) { 122 | throw new Error('No user found for provided loginKey'); 123 | } 124 | user = user[0]; 125 | return user; 126 | }).catch(function(err) { 127 | throw new Err('UserStore.ReadError', { 128 | // Pass the original error as a cause, so caller can inspect it 129 | cause: err 130 | }); 131 | }); 132 | }; 133 | 134 | /* 135 | Get user record for a given email 136 | 137 | @param {String} email user email 138 | */ 139 | this.getUserByEmail = function(email) { 140 | var query = this.db('users') 141 | .where('email', email); 142 | 143 | return query 144 | .then(function(user) { 145 | if (user.length === 0) { 146 | throw new Err('UserStore.ReadError', { 147 | // Pass the original error as a cause 148 | message: 'There is no user with email ' + email 149 | }); 150 | } 151 | user = user[0]; 152 | return user; 153 | }); 154 | }; 155 | 156 | /* 157 | Internal method to create a user entry 158 | */ 159 | this._createUser = function(userData) { 160 | // at some point we should make this more secure 161 | var loginKey = userData.loginKey || uuid(); 162 | 163 | var user = { 164 | userId: userData.userId, 165 | name: userData.name, 166 | email: userData.email, 167 | createdAt: Date.now(), 168 | loginKey: loginKey 169 | }; 170 | 171 | return this.db.table('users').insert(user) 172 | .then(function() { 173 | // We want to confirm the insert with the created user entry 174 | return user; 175 | }).catch(function(err) { 176 | throw new Err('UserStore.CreateError', { 177 | // Pass the original error as a cause 178 | cause: err 179 | }); 180 | }); 181 | }; 182 | 183 | /* 184 | Check if user exists 185 | */ 186 | this.userExists = function(id) { 187 | var query = this.db('users') 188 | .where('userId', id) 189 | .limit(1); 190 | 191 | return query.then(function(user) { 192 | if (user.length === 0) return false; 193 | return true; 194 | }); 195 | }; 196 | 197 | /* 198 | Resets the database and loads a given seed object 199 | 200 | Be careful with running this in production 201 | 202 | @param {Object} seed JSON object 203 | */ 204 | this.seed = function(seed) { 205 | var self = this; 206 | var actions = map(seed, self.createUser.bind(self)); 207 | return Promise.all(actions); 208 | }; 209 | 210 | }; 211 | 212 | oo.initClass(UserStore); 213 | 214 | module.exports = UserStore; 215 | -------------------------------------------------------------------------------- /styles/_collaborators.scss: -------------------------------------------------------------------------------- 1 | 2 | .sc-collaborators { 3 | float: right; 4 | margin-right: 10px; 5 | 6 | .se-collaborator { 7 | margin: 6px 3px; 8 | display: inline-block; 9 | width: 26px; 10 | height: 26px; 11 | border-radius: 50%; 12 | line-height: 26px; 13 | text-align: center; 14 | font-weight: 600; 15 | font-size: 12px; 16 | color: #42423E; 17 | transition: all 0.5s ease; 18 | 19 | &.sm-collaborator-1 { 20 | background: $collaborator-color-1; 21 | &.active { color: $collaborator-color-1; background: rgba($collaborator-color-1, 0.25); } 22 | } 23 | &.sm-collaborator-2 { 24 | background: $collaborator-color-2; 25 | &.active { color: $collaborator-color-2; background: rgba($collaborator-color-2, 0.25); } 26 | } 27 | &.sm-collaborator-3 { 28 | background: $collaborator-color-3; 29 | &.active { color: $collaborator-color-3; background: rgba($collaborator-color-3, 0.25); } 30 | } 31 | &.sm-collaborator-4 { 32 | background: $collaborator-color-4; 33 | &.active { color: $collaborator-color-4; background: rgba($collaborator-color-4, 0.25); } 34 | } 35 | &.sm-collaborator-5 { 36 | background: $collaborator-color-5; 37 | &.active { color: $collaborator-color-5; background: rgba($collaborator-color-5, 0.25); } 38 | } 39 | &.sm-collaborator-6 { 40 | background: $collaborator-color-6; 41 | &.active { color: $collaborator-color-6; background: rgba($collaborator-color-6, 0.25); } 42 | } 43 | &.sm-collaborator-7 { 44 | background: $collaborator-color-7; 45 | &.active { color: $collaborator-color-7; background: rgba($collaborator-color-7, 0.25); } 46 | } 47 | &.sm-collaborator-8 { 48 | background: $collaborator-color-8; 49 | &.active { color: $collaborator-color-8; background: rgba($collaborator-color-8, 0.25); } 50 | } 51 | &.sm-collaborator-9 { 52 | background: $collaborator-color-9; 53 | &.active { color: $collaborator-color-9; background: rgba($collaborator-color-9, 0.25); } 54 | } 55 | &.sm-collaborator-10 { 56 | background: $collaborator-color-10; 57 | &.active { color: $collaborator-color-10; background: rgba($collaborator-color-10, 0.25); } 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /styles/_comment.scss: -------------------------------------------------------------------------------- 1 | .sc-comment { 2 | position: relative; 3 | background: #f7f9f9; 4 | color: #8c9393; 5 | font-size: 14px; 6 | border-bottom: 1px solid #eaefef; 7 | padding: 10px 20px; 8 | margin-bottom: 20px; 9 | overflow: auto; 10 | 11 | .se-comment-symbol { 12 | color: #e0e2e2; 13 | position: absolute; 14 | top: 10px; 15 | right: 10px; 16 | } 17 | 18 | .se-authored { 19 | color: #bfc4c4; 20 | font-style: italic; 21 | -moz-user-select: none; 22 | -khtml-user-select: none; 23 | -webkit-user-select: none; 24 | -o-user-select: none; 25 | } 26 | } -------------------------------------------------------------------------------- /styles/_cover.scss: -------------------------------------------------------------------------------- 1 | .sc-cover { 2 | margin-bottom: 2*$default-padding; 3 | 4 | .se-title { 5 | font-size: $title-font-size; 6 | padding: $default-padding 0; 7 | font-weight: $strong-font-weight; 8 | text-align: center; 9 | } 10 | 11 | .se-separator { 12 | height: 1px; 13 | background: $border-color; 14 | max-width: 200px; 15 | margin: $default-padding auto; 16 | } 17 | 18 | .se-authors { 19 | text-align: center; 20 | padding-bottom: 2*$default-padding; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /styles/_dashboard.scss: -------------------------------------------------------------------------------- 1 | .sc-dashboard { 2 | .se-intro { 3 | padding-bottom: $default-padding; 4 | overflow: hidden; 5 | 6 | .se-note-count { 7 | float: left; 8 | color: $light-text-color; 9 | vertical-align: middle; 10 | line-height: $base-height; 11 | } 12 | 13 | .se-new-note-button { 14 | float: right; 15 | } 16 | } 17 | 18 | .sc-note-item { 19 | border-top: 1px solid $border-color; 20 | // padding-bottom: $default-padding; 21 | padding-top: $default-padding; 22 | margin-bottom: $default-padding; 23 | } 24 | } -------------------------------------------------------------------------------- /styles/_enter-name.scss: -------------------------------------------------------------------------------- 1 | .sc-enter-name { 2 | 3 | p.se-help { 4 | font-size: 18px; 5 | margin: 0px 50px; 6 | } 7 | 8 | .se-enter-name { 9 | padding: $default-padding 0; 10 | } 11 | } -------------------------------------------------------------------------------- /styles/_header.scss: -------------------------------------------------------------------------------- 1 | .sc-header { 2 | height: 40px; 3 | background: #3B4749; 4 | overflow: visible; 5 | font-size: $small-font-size; 6 | font-weight: $strong-font-weight; 7 | 8 | .se-actions { 9 | float: left; 10 | .se-action { 11 | line-height: 40px; 12 | display: inline-block; 13 | padding: 0px 20px; 14 | color: rgba(255,255,255, 0.7); 15 | 16 | &:focus { 17 | color: rgba(255,255,255, 1); 18 | outline: none; 19 | } 20 | 21 | &:hover { 22 | color: rgba(255,255,255, 1); 23 | } 24 | } 25 | } 26 | 27 | .se-content { 28 | float: right; 29 | } 30 | 31 | // Override notification styles when 32 | // rendered inside header 33 | .sc-notification { 34 | background-color: #5e696d; 35 | color: #C8D0DA; 36 | min-width: 400px; 37 | margin: 8px 14px; 38 | padding: 0px 20px; 39 | text-align: center; 40 | border-radius: 3px; 41 | 42 | &.se-type-error { 43 | border: none; 44 | background-color: #6c6661; 45 | color: #FFC2A6; 46 | } 47 | 48 | &.se-type-warning { 49 | border: none; 50 | background-color: #62644B; 51 | color: #D6BB4C; 52 | } 53 | 54 | &.se-type-success { 55 | border: none; 56 | background-color: #51726a; 57 | color: #8EF1CC; 58 | } 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /styles/_login-status.scss: -------------------------------------------------------------------------------- 1 | .sc-login-status { 2 | float: right; 3 | padding: 0px 15px; 4 | color: white; 5 | line-height: 40px; 6 | border-left: 1px solid #2E393A; 7 | } 8 | 9 | // TODO: .sc- is reserved for components, while inside components you 10 | // would use .se-. So this would be called .se-dropdown and go inside 11 | // .sc-login-status 12 | .se-dropdown { 13 | .se-caret { 14 | padding-left: 10px; 15 | font-size: 24px; 16 | position: relative; 17 | top: 3px; 18 | color: #6B7B7C; 19 | } 20 | 21 | ul { 22 | display: none; 23 | position: absolute; 24 | background: #3B4749; 25 | padding: 0px 15px; 26 | margin: 0px -15px; 27 | list-style: none; 28 | z-index: 99; 29 | width: 100%; 30 | 31 | li { 32 | width: 100px; 33 | cursor: pointer; 34 | } 35 | } 36 | 37 | &:hover { 38 | ul { 39 | display: block; 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /styles/_mark.scss: -------------------------------------------------------------------------------- 1 | .sc-mark { 2 | // background: rgba(234, 231, 35, 0.3); 3 | border-bottom: 3px solid rgba(234, 231, 35, 0.5); 4 | } 5 | -------------------------------------------------------------------------------- /styles/_note-item.scss: -------------------------------------------------------------------------------- 1 | /* Used in Dashboard */ 2 | 3 | .sc-note-item { 4 | 5 | .se-title { 6 | color: $link-color; 7 | font-weight: $strong-font-weight; 8 | font-size: $large-font-size; 9 | padding-bottom: $default-padding / 2; 10 | } 11 | 12 | .se-preview { 13 | font-size: $small-font-size; 14 | padding-bottom: $default-padding / 2; 15 | } 16 | 17 | 18 | .se-meta { 19 | font-size: $small-font-size; 20 | 21 | .se-meta-item { 22 | display: inline-block; 23 | margin-right: $default-padding; 24 | margin-bottom: $default-padding/2; 25 | } 26 | 27 | .se-updated-at { 28 | color: $light-text-color; 29 | } 30 | 31 | .se-delete { 32 | color: $link-color; 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /styles/_note-summary.scss: -------------------------------------------------------------------------------- 1 | .sc-note-summary { 2 | padding: $default-padding / 2; 3 | // we use margin-bottom of .se-item as a spacer 4 | padding-bottom: 0px; 5 | // background: $fill-light-color; 6 | font-size: $small-font-size; 7 | color: $light-text-color; 8 | border-radius: $border-radius; 9 | border: 1px solid $border-color; 10 | text-align: center; 11 | 12 | .se-item { 13 | display: inline-block; 14 | margin-right: 2 * $default-padding; 15 | margin-bottom: $default-padding/2; 16 | } 17 | 18 | .se-issues-bar { 19 | min-width: 130px; 20 | border: 1px $border-color solid; 21 | border-radius: $border-radius; 22 | height: $base-height / 2 - $base-height / 8; 23 | display: inline-block; 24 | margin-right: $default-padding/2; 25 | margin-left: $default-padding/2; 26 | 27 | // HACK: just to tweak the vertical alignment with text 28 | top: 2px; 29 | position: relative; 30 | 31 | .se-completed { 32 | float: left; 33 | height: 100%; 34 | background: $border-color; 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /styles/_note-writer.scss: -------------------------------------------------------------------------------- 1 | .sc-note-writer { 2 | 3 | .se-toolbar-wrapper { 4 | border-bottom: 1px solid $border-color; 5 | } 6 | 7 | .sc-paragraph { 8 | padding-bottom: $default-padding; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /styles/_notes.scss: -------------------------------------------------------------------------------- 1 | .sc-app { 2 | // font-family: 'Lato', sans-serif; 3 | 4 | .se-error { 5 | position: absolute; 6 | top: 0; 7 | left: 0; 8 | margin: auto; 9 | color: #fff; 10 | width: 100%; 11 | height: 20px; 12 | padding: 2px; 13 | text-align: center; 14 | background: #FF7B7B; 15 | box-shadow: 0 6px 6px -6px black; 16 | transition: all 0.5s ease; 17 | z-index: 9999; 18 | font-size: 13px; 19 | 20 | .se-dismiss { 21 | right: 30px; 22 | position: absolute; 23 | cursor: pointer; 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /styles/_notification.scss: -------------------------------------------------------------------------------- 1 | // Notification styles (when rendered on white main section) 2 | 3 | .sc-notification { 4 | color: #777C82; 5 | border: 1px solid #777C82; 6 | margin: 50px; 7 | padding: $default-padding; 8 | text-align: center; 9 | 10 | &.se-type-error { 11 | border: 1px solid #FF7B7A; 12 | color: #FF7B7A; 13 | } 14 | 15 | &.se-type-warning { 16 | border: 1px solid #D6BB4C; 17 | color: #D6BB4C; 18 | } 19 | 20 | &.se-type-success { 21 | border: 1px solid #24D49A; 22 | color: #24D49A; 23 | } 24 | } -------------------------------------------------------------------------------- /styles/_request-login.scss: -------------------------------------------------------------------------------- 1 | .sc-request-login { 2 | .se-email { 3 | padding-bottom: $default-padding; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /styles/_shared.scss: -------------------------------------------------------------------------------- 1 | /* Shared (global) styles */ 2 | 3 | .sg-confirm-button { 4 | padding: 10px 5px; 5 | margin-top: 40px; 6 | margin-left: auto; 7 | margin-right: auto; 8 | min-width: 300px; 9 | text-align: center; 10 | font-size: 16px; 11 | font-weight: 600; 12 | border: 2px solid #3B4749; 13 | } 14 | 15 | .sg-confirm-button:focus { 16 | outline: 2px solid #5BE3FF; 17 | } -------------------------------------------------------------------------------- /styles/_todo.scss: -------------------------------------------------------------------------------- 1 | .sc-todo { 2 | padding-left: 20px; 3 | padding-bottom: 20px; 4 | position: relative; 5 | 6 | .se-done { 7 | display: inline-block; 8 | width: 20px; 9 | cursor: pointer; 10 | 11 | position: absolute; 12 | left: 0px; 13 | 14 | // This is really important because otherwise the DOM selection also covers 15 | // the previous line when you double click the first word of a todo item. 16 | -moz-user-select: none; 17 | -khtml-user-select: none; 18 | -webkit-user-select: none; 19 | -o-user-select: none; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /styles/_welcome.scss: -------------------------------------------------------------------------------- 1 | .sc-welcome { 2 | 3 | .se-cursor { 4 | display: inline-block; 5 | background: #7BB5B3; 6 | width: 2px; 7 | } 8 | 9 | // cursor must have some content, otherwise it won't be displayed inline 10 | .se-cursor::after { content: "."; opacity: 0; } 11 | 12 | .se-topbar { 13 | height: 15px; 14 | background-image: url(/assets/img/welcome_header.png); 15 | } 16 | 17 | } -------------------------------------------------------------------------------- /styles/assets/img/welcome_header.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/substance/notes/399bc4d1abc008ed7a614487ca48a333af7da530/styles/assets/img/welcome_header.png -------------------------------------------------------------------------------- /test/db.js: -------------------------------------------------------------------------------- 1 | var Database = require('../server/Database'); 2 | Database.instance = new Database(); 3 | 4 | module.exports = Database.instance; -------------------------------------------------------------------------------- /test/qunit_extensions.js: -------------------------------------------------------------------------------- 1 | require('substance/test/unit/qunit_extensions'); -------------------------------------------------------------------------------- /test/run.js: -------------------------------------------------------------------------------- 1 | var glob = require('glob'); 2 | var path = require('path'); 3 | var each = require('lodash/each'); 4 | var QUnit = require('qunitjs'); 5 | var colors = require('colors'); 6 | 7 | var db = require('./db'); 8 | 9 | global.QUnit = QUnit; 10 | 11 | var files = glob.sync('**/*/*.test.js', {cwd: 'test'}); 12 | each(files, function(file) { 13 | require('./' + file); 14 | }); 15 | 16 | var lastModule = null; 17 | var lastTestName = null; 18 | var count = 0; 19 | var fails = 0; 20 | 21 | 22 | QUnit.log(function(data) { 23 | count++; 24 | if (!data.result) { 25 | if (data.module !== lastModule) { 26 | console.log(data.module); 27 | lastModule = data.module; 28 | } 29 | if (data.name !== lastTestName) { 30 | console.log(' ' + data.name); 31 | lastTestName = data.name; 32 | } 33 | console.log(' - ', data.message || "", ' ', data.result ? 'ok' : 'failed'); 34 | console.log(' expected: ', data.expected, '; actual: ', data.actual); 35 | console.log(data.source); 36 | // fails.push({ 37 | // module: data.module, 38 | // name: data.name, 39 | // msg: data.message || "", 40 | // }) 41 | fails++; 42 | // console.log('#### log', data); 43 | } 44 | }); 45 | 46 | QUnit.done(function(data) { 47 | db.shutdown(); 48 | if (fails > 0) { 49 | console.error('FAILED: %d tests of %d failed'.red, fails, count); 50 | process.exit(1); 51 | } else { 52 | console.log('YAY: %d tests passed'.green, count); 53 | } 54 | }); 55 | 56 | console.log(''); 57 | console.log('#####################################'.yellow); 58 | console.log('Running tests in node.js...'.yellow); 59 | console.log('#####################################'.yellow); 60 | console.log(''); 61 | QUnit.load(); -------------------------------------------------------------------------------- /test/server/AuthenticationEngine.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../qunit_extensions'); 4 | 5 | var UserStore = require('../../server/UserStore'); 6 | var SessionStore = require('../../server/SessionStore'); 7 | var AuthenticationEngine = require('../../server/AuthenticationEngine'); 8 | var db = require('../db'); 9 | var userStore, sessionStore, engine; 10 | 11 | QUnit.module('server/AuthenticationEngine', { 12 | beforeEach: function() { 13 | return db.reset() 14 | .then(function() { 15 | userStore = new UserStore({ db: db}); 16 | return userStore.seed({ 17 | '1': { 18 | userId: '1', 19 | name: 'Test', 20 | loginKey: '1234', 21 | email: 'test@example.com' 22 | } 23 | }); 24 | }).then(function() { 25 | sessionStore = new SessionStore({ db: db }); 26 | return sessionStore.seed({ 27 | 'user1token': { 28 | sessionToken: 'user1token', 29 | userId: '1' 30 | } 31 | }); 32 | }).then(function() { 33 | engine = new AuthenticationEngine({ 34 | sessionStore: sessionStore, 35 | userStore: userStore 36 | }); 37 | }); 38 | } 39 | }); 40 | 41 | QUnit.test('Authenticate with session token', function(assert) { 42 | assert.expect(5); 43 | var sessionToken = 'user1token'; 44 | return engine.authenticate({sessionToken: sessionToken}) 45 | .then(function(session) { 46 | assert.ok(session, 'Session should be returned'); 47 | assert.ok(session.user, 'Session should have a rich user object'); 48 | assert.ok(session.sessionToken, 'Session should have a sessionToken'); 49 | assert.equal(session.user.userId, '1', 'userId should be "1"'); 50 | assert.equal(session.userId, '1', 'userId should be "1"'); 51 | }); 52 | }); 53 | 54 | QUnit.test('Authenticate with wrong session token', function(assert) { 55 | assert.expect(1); 56 | return engine.authenticate({sessionToken: 'xyz'}).catch(function(err) { 57 | assert.equal(err.name, 'AuthenticationError', 'Should throw the right error'); 58 | }); 59 | }); 60 | 61 | QUnit.test('Authenticate with loginKey', function(assert) { 62 | assert.expect(5); 63 | var loginKey = '1234'; 64 | return engine.authenticate({loginKey: loginKey}) 65 | .then(function(session) { 66 | assert.ok(session, 'Session should be returned'); 67 | assert.ok(session.user, 'Session should have a rich user object'); 68 | assert.ok(session.sessionToken, 'Session should have a sessionToken'); 69 | assert.equal(session.user.userId, '1', 'userId should be "1"'); 70 | assert.equal(session.userId, '1', 'userId should be "1"'); 71 | }); 72 | }); 73 | 74 | QUnit.test('Authenticate with wrong loginKey', function(assert) { 75 | assert.expect(1); 76 | return engine.authenticate({loginKey: 'xyz'}).catch(function(err) { 77 | assert.equal(err.name, 'AuthenticationError', 'Should throw the right error'); 78 | }); 79 | }); 80 | 81 | // Email system tests 82 | 83 | // QUnit.test('Request login link for an existing email', function(assert) { 84 | // assert.expect(1); 85 | // return engine.requestLoginLink({email: 'test@example.com'}).then(function(result) { 86 | // assert.ok(result.loginKey, 'There should be a new login key'); 87 | // }); 88 | // }); 89 | 90 | // QUnit.test('Request login link for an email that does not exist', function(assert) { 91 | // assert.expect(2); 92 | // return engine.requestLoginLink({email: 'other@email.com'}).then(function(result) { 93 | // assert.ok(result.loginKey, 'There should be a new login key'); 94 | // return userStore.getUserByEmail('other@email.com'); 95 | // }) 96 | // .then(function(user) { 97 | // assert.ok(user, 'There should be a new user in the database'); 98 | // }); 99 | // }); 100 | 101 | // QUnit.test('Request login link for an invalid email should error', function(assert) { 102 | // assert.expect(1); 103 | // return engine.requestLoginLink({email: 'foo/bar'}).catch(function(err) { 104 | // assert.equal(err.message, 'invalid-email'); 105 | // }); 106 | // }); 107 | -------------------------------------------------------------------------------- /test/server/ChangeStore.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../qunit_extensions'); 4 | 5 | var db = require('../db'); 6 | var changeStoreSeed = require('substance/test/fixtures/collab/changeStoreSeed'); 7 | var ChangeStore = require('../../server/ChangeStore'); 8 | var changeStore = new ChangeStore({ db: db }); 9 | 10 | var testChangeStore = require('substance/test/collab/testChangeStore'); 11 | 12 | QUnit.module('server/ChangeStore', { 13 | beforeEach: function() { 14 | return db.reset() 15 | .then(function() { 16 | var newChangeStoreSeed = JSON.parse(JSON.stringify(changeStoreSeed)); 17 | return changeStore.seed(newChangeStoreSeed); 18 | }); 19 | } 20 | }); 21 | 22 | // Runs the offical document store test suite 23 | testChangeStore(changeStore, QUnit); -------------------------------------------------------------------------------- /test/server/DocumentStore.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../qunit_extensions'); 4 | 5 | var db = require('../db'); 6 | var testDocumentStore = require('substance/test/collab/testDocumentStore'); 7 | var DocumentStore = require('../../server/DocumentStore'); 8 | 9 | var documentStoreSeed = { 10 | 'test-doc': { 11 | documentId: 'test-doc', 12 | schemaName: 'prose-article', 13 | schemaVersion: '1.0.0', 14 | version: 1, 15 | info: { 16 | userId: 1 17 | } 18 | } 19 | }; 20 | 21 | var documentStore = new DocumentStore({ db: db }); 22 | 23 | QUnit.module('server/DocumentStore', { 24 | beforeEach: function() { 25 | return db.reset() 26 | .then(function() { 27 | var newDocumentStoreSeed = JSON.parse(JSON.stringify(documentStoreSeed)); 28 | return documentStore.seed(newDocumentStoreSeed); 29 | }); 30 | } 31 | }); 32 | 33 | // Runs the offical document store test suite 34 | testDocumentStore(documentStore, QUnit); 35 | 36 | QUnit.test('List documents', function(assert) { 37 | var done = assert.async(); 38 | documentStore.listDocuments({}, function(err, documents) { 39 | assert.notOk(err, 'Should not error'); 40 | assert.equal(documents.length, 1, 'There should be one document returned'); 41 | assert.equal(documents[0].userId, '1', 'First doc should have userId "1"'); 42 | assert.equal(documents[0].documentId, 'test-doc', 'documentId should be "test-doc"'); 43 | done(); 44 | }); 45 | }); 46 | 47 | QUnit.test('List documents with matching filter', function(assert) { 48 | var done = assert.async(); 49 | documentStore.listDocuments({userId: '1'}, function(err, documents) { 50 | assert.notOk(err, 'Should not error'); 51 | assert.equal(documents.length, 1, 'There should be one document returned'); 52 | assert.equal(documents[0].userId, '1', 'First doc should have userId "1"'); 53 | assert.equal(documents[0].documentId, 'test-doc', 'documentId should be "test-doc"'); 54 | done(); 55 | }); 56 | }); 57 | 58 | QUnit.test('List documents with filter that does not match', function(assert) { 59 | var done = assert.async(); 60 | documentStore.listDocuments({userId: 'userx'}, function(err, documents) { 61 | assert.notOk(err, 'Should not error'); 62 | assert.equal(documents.length, 0, 'There should be no matches'); 63 | done(); 64 | }); 65 | }); -------------------------------------------------------------------------------- /test/server/SessionStore.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../qunit_extensions'); 4 | 5 | var db = require('../db'); 6 | var SessionStore = require('../../server/SessionStore'); 7 | var sessionStore = new SessionStore({ db: db }); 8 | 9 | QUnit.module('server/SessionStore', { 10 | beforeEach: function() { 11 | return db.reset() 12 | .then(function() { 13 | return sessionStore.seed({ 14 | 'user1token': { 15 | 'userId': 'testuser', 16 | 'sessionToken': 'user1token' 17 | } 18 | }); 19 | }); 20 | } 21 | }); 22 | 23 | QUnit.test('Create session', function(assert) { 24 | assert.expect(1); 25 | return sessionStore.createSession({userId: 'testuser'}) 26 | .then(function(session) { 27 | assert.equal(session.userId, 'testuser', 'Session should be associated with testuser'); 28 | }); 29 | }); 30 | 31 | QUnit.test('Get an existing session', function(assert) { 32 | assert.expect(2); 33 | return sessionStore.getSession('user1token') 34 | .then(function(session) { 35 | assert.equal(session.sessionToken, 'user1token', 'Session token should match'); 36 | assert.equal(session.userId, 'testuser', 'Session should be associated with testuser'); 37 | }); 38 | }); 39 | 40 | QUnit.test('Get a non-existent session', function(assert) { 41 | assert.expect(1); 42 | return sessionStore.getSession('user2token').catch(function(err){ 43 | assert.equal(err.message, 'No session found for token user2token', 'Should return session not found error'); 44 | }); 45 | }); 46 | 47 | QUnit.test('Delete existing session', function(assert) { 48 | assert.expect(3); 49 | return sessionStore.deleteSession('user1token') 50 | .then(function(session) { 51 | assert.equal(session.sessionToken, 'user1token', 'Deleted session token should match'); 52 | assert.equal(session.userId, 'testuser', 'Deleted session should be associated with user testuser'); 53 | return sessionStore.getSession('user1token'); 54 | }).catch(function(err){ 55 | assert.equal(err.message, 'No session found for token user1token', 'Should return session not found error'); 56 | }); 57 | }); 58 | 59 | QUnit.test('Delete a non-existent session', function(assert) { 60 | assert.expect(1); 61 | return sessionStore.deleteSession('user2token').catch(function(err) { 62 | assert.equal(err.name, 'SessionStore.DeleteError', 'Should throw the right error'); 63 | }); 64 | }); -------------------------------------------------------------------------------- /test/server/SnapshotEngine.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../qunit_extensions'); 4 | 5 | var documentStoreSeed = require('substance/test/fixtures/collab/documentStoreSeed'); 6 | var changeStoreSeed = require('substance/test/fixtures/collab/changeStoreSeed'); 7 | var snapshotStoreSeed = require('substance/test/fixtures/collab/snapshotStoreSeed'); 8 | 9 | var DocumentStore = require('../../server/DocumentStore'); 10 | var SnapshotStore = require('../../server/SnapshotStore'); 11 | var ChangeStore = require('../../server/ChangeStore'); 12 | 13 | var SnapshotEngine = require('substance/collab/SnapshotEngine'); 14 | var testSnapshotEngine = require('substance/test/collab/testSnapshotEngine'); 15 | var testSnapshotEngineWithStore = require('substance/test/collab/testSnapshotEngineWithStore'); 16 | var twoParagraphs = require('substance/test/fixtures/collab/two-paragraphs'); 17 | 18 | var db = require('../db'); 19 | 20 | /* 21 | These can be considered integration tests for the custom SnapshotStore implementation. 22 | It just tests with real use-cases like requesting a snapshot which involves a fetching 23 | the closest available snapshot in the store plus applying additional changes. 24 | */ 25 | 26 | var documentStore = new DocumentStore({db: db}); 27 | var changeStore = new ChangeStore({db: db}); 28 | var snapshotStore = new SnapshotStore({db: db}); 29 | 30 | var snapshotEngine = new SnapshotEngine({ 31 | documentStore: documentStore, 32 | changeStore: changeStore, 33 | schemas: { 34 | 'prose-article': { 35 | name: 'prose-article', 36 | version: '1.0.0', 37 | documentFactory: twoParagraphs 38 | } 39 | } 40 | }); 41 | 42 | var snapshotEngineWithStore = new SnapshotEngine({ 43 | documentStore: documentStore, 44 | changeStore: changeStore, 45 | snapshotStore: snapshotStore, 46 | schemas: { 47 | 'prose-article': { 48 | name: 'prose-article', 49 | version: '1.0.0', 50 | documentFactory: twoParagraphs 51 | } 52 | } 53 | }); 54 | 55 | QUnit.module('collab/SnapshotEngine', { 56 | beforeEach: function() { 57 | var newDocumentStoreSeed = JSON.parse(JSON.stringify(documentStoreSeed)); 58 | var newChangeStoreSeed = JSON.parse(JSON.stringify(changeStoreSeed)); 59 | var newSnapshotStoreSeed = JSON.parse(JSON.stringify(snapshotStoreSeed)); 60 | 61 | return db.reset().then(function() { 62 | return documentStore.seed(newDocumentStoreSeed); 63 | }).then(function() { 64 | return changeStore.seed(newChangeStoreSeed); 65 | }).then(function() { 66 | return snapshotStore.seed(newSnapshotStoreSeed); 67 | }); 68 | } 69 | }); 70 | 71 | // Run the generic testsuite with an engine that does not have a store attached 72 | testSnapshotEngine(snapshotEngine, twoParagraphs, QUnit); 73 | // Run the same testsuite but this time with a store 74 | testSnapshotEngine(snapshotEngineWithStore, twoParagraphs, QUnit); 75 | 76 | // Run tests that are only relevant when a snapshot store is provided to the engine 77 | testSnapshotEngineWithStore(snapshotEngineWithStore, twoParagraphs, QUnit); -------------------------------------------------------------------------------- /test/server/SnapshotStore.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../qunit_extensions'); 4 | 5 | var snapshotStoreSeed = require('substance/test/fixtures/collab/snapshotStoreSeed'); 6 | var SnapshotStore = require('../../server/SnapshotStore'); 7 | var testSnapshotStore = require('substance/test/collab/testSnapshotStore'); 8 | var db = require('../db'); 9 | var snapshotStore = new SnapshotStore({db: db}); 10 | 11 | QUnit.module('collab/SnapshotStore', { 12 | beforeEach: function() { 13 | // Make sure we create a new seed instance, as data ops 14 | // are performed directly on the seed object 15 | var newSnapshotStoreSeed = JSON.parse(JSON.stringify(snapshotStoreSeed)); 16 | return db.reset().then(function() { 17 | return snapshotStore.seed(newSnapshotStoreSeed); 18 | }); 19 | } 20 | }); 21 | 22 | // Runs the offical backend test suite 23 | testSnapshotStore(snapshotStore, QUnit); -------------------------------------------------------------------------------- /test/server/UserStore.test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | require('../qunit_extensions'); 4 | 5 | var UserStore = require('../../server/UserStore'); 6 | var db = require('../db'); 7 | var userStore = new UserStore({ db: db }); 8 | 9 | QUnit.module('server/UserStore', { 10 | beforeEach: function() { 11 | return db.reset() 12 | .then(function() { 13 | return userStore.seed({ 14 | 'testuser': { 15 | userId: 'testuser', 16 | name: 'Test', 17 | loginKey: '1234', 18 | email: 'test@example.com' 19 | } 20 | }); 21 | }); 22 | } 23 | }); 24 | 25 | QUnit.test('Get user', function(assert) { 26 | assert.expect(1); 27 | return userStore.getUser('testuser') 28 | .then(function(user) { 29 | assert.equal(user.userId, 'testuser'); 30 | }); 31 | }); 32 | 33 | QUnit.test('Get user that does not exist', function(assert) { 34 | assert.expect(1); 35 | 36 | // TODO: I found no better way then using catch here. 37 | // Catch is bad because it could be some other error (e.g. syntax error) and 38 | // then we would wrongly assume the test succeeded AND we also loose the 39 | // stack trace of the error. 40 | // We do an explicit check of the error message now, so we can be sure 41 | // it's the right error. However that's not really ideal. Better would be using 42 | // assert.throws but that is not compatible with promises it seems. 43 | return userStore.getUser('userx').catch(function(err) { 44 | assert.equal(err.message, 'No user found for userId userx', 'Should be user not found error'); 45 | }); 46 | }); 47 | 48 | QUnit.test('Create a new user', function(assert) { 49 | assert.expect(2); 50 | return userStore.createUser({email: 'test2@example.com'}) 51 | .then(function(user) { 52 | assert.ok(user.userId, 'New user should have a userId'); 53 | assert.equal(user.email, 'test2@example.com', 'email should be "test2@example.com"'); 54 | }); 55 | }); 56 | 57 | QUnit.test('Create a new user that already exists', function(assert) { 58 | // TODO: again we need to use catch (see explanation above) 59 | assert.expect(1); 60 | return userStore.createUser({userId: 'testuser'}) 61 | .catch(function(err) { 62 | assert.equal(err.name, 'UserStore.CreateError', 'Should throw the right error'); 63 | }); 64 | }); 65 | 66 | QUnit.test('Create a new user with existing email', function(assert) { 67 | return userStore.createUser({'email': 'test@example.com'}) 68 | .catch(function(err) { 69 | // TODO: we should be more restrictive here. We receive a SQLConstraint error 70 | // however we should throw something custom that does not reveal our DB layout 71 | // console.log(err); 72 | assert.ok(err, 'Creating a new user with existing email should error'); 73 | }); 74 | }); 75 | 76 | QUnit.test('Update a user record', function(assert) { 77 | assert.expect(1); 78 | return userStore.updateUser('testuser', {'name': 'voodoo'}) 79 | .then(function(user) { 80 | assert.equal(user.name, 'voodoo', 'user name should be "voodoo"'); 81 | return userStore.getUser('testuser'); 82 | }); 83 | }); 84 | 85 | QUnit.test('Update email of a user record', function(assert) { 86 | assert.expect(2); 87 | return userStore.updateUser('testuser', {'email': 'other@email.com'}).then(function(user) { 88 | assert.equal(user.email, 'other@email.com'); 89 | return userStore.getUserByEmail('test@example.com').catch(function(err) { 90 | assert.ok(err, 'Email test@example.com should no longer exist'); 91 | }); 92 | }); 93 | }); 94 | 95 | QUnit.test('Remove a user record', function(assert) { 96 | assert.expect(2); 97 | return userStore.deleteUser('testuser') 98 | .then(function(user) { 99 | assert.equal(user.userId, 'testuser', 'Deleted user record should be returned'); 100 | return userStore.userExists('testuser'); 101 | }).then(function(exists) { 102 | assert.notOk(exists, 'testuser should no longer exist.'); 103 | }); 104 | }); 105 | --------------------------------------------------------------------------------