├── .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 [](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 |
--------------------------------------------------------------------------------