├── .gitignore ├── .meteor ├── .gitignore ├── packages └── release ├── LICENSE ├── NOTES.md ├── README.md ├── client ├── jotgit.coffee ├── jotgit.css └── jotgit.jade ├── lib └── jotgit.coffee ├── packages ├── .gitignore ├── chokidar │ ├── .gitignore │ ├── .npm │ │ └── package │ │ │ ├── .gitignore │ │ │ ├── README │ │ │ └── npm-shrinkwrap.json │ ├── chokidar.js │ └── package.js └── jotgit-core │ ├── .gitignore │ ├── .npm │ └── package │ │ ├── .gitignore │ │ ├── README │ │ └── npm-shrinkwrap.json │ ├── client.js │ ├── editor-server.coffee │ ├── jotgit.coffee │ ├── package.js │ └── repo.coffee ├── public └── images │ └── logos.png ├── server └── jotgit.coffee ├── smart.json ├── smart.lock └── tests └── demo ├── article.md └── chapter1.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.dylib 2 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # 3 | # 'meteor add' and 'meteor remove' will edit this file for you, 4 | # but you can also edit it by hand. 5 | 6 | standard-app-packages 7 | insecure 8 | coffeescript 9 | jquery 10 | bower 11 | iron-router 12 | jade 13 | jotgit-core 14 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | 0.8.2 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 John Lees-Miller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /NOTES.md: -------------------------------------------------------------------------------- 1 | # Setup Notes 2 | 3 | ## 2014-05-17 4 | 5 | on node 0.10.28 with meteor 0.8.1.2 6 | 7 | nvm use 0.10 8 | npm install -g meteorite 9 | 10 | It looks like nodegit is built with an absolute path to the libgit2 dylib, but 11 | meteor moves things around in the course of the build process and apparently 12 | breaks it. 13 | 14 | Workaround is to add a console.log(e) to 15 | `packages/nodegit/.build/npm/node_modules/nodegit/index.js` 16 | like this: 17 | ``` 18 | var rawApi; 19 | try { 20 | rawApi = require('./build/Release/nodegit'); 21 | } catch (e) { 22 | console.log(e); 23 | rawApi = require('./build/Debug/nodegit'); 24 | } 25 | ``` 26 | 27 | Error looks like: 28 | 29 | ``` 30 | I20140517-17:50:55.827(1)? [Error: dlopen(/Users/john/ex/gitrt/packages/nodegit/.build/npm/node_modules/nodegit/build/Release/nodegit.node, 1): Library not loaded: /Users/john/ex/gitrt/packages/nodegit/.npm/package-new-1s6evra/node_modules/nodegit/vendor/libgit2/build/libgit2.0.dylib 31 | I20140517-17:50:55.895(1)? Referenced from: /Users/john/ex/gitrt/packages/nodegit/.build/npm/node_modules/nodegit/build/Release/nodegit.node 32 | I20140517-17:50:55.895(1)? Reason: image not found] 33 | ``` 34 | 35 | So created symlink: 36 | ``` 37 | mkdir -p packages/nodegit/.npm/package-new-1s6evra/node_modules/nodegit/vendor/libgit2/build/ 38 | ln -s $PWD/packages/nodegit/.build/npm/node_modules/nodegit/vendor/libgit2/build/libgit2.0.dylib packages/nodegit/.npm/package-new-1s6evra/node_modules/nodegit/vendor/libgit2/build/libgit2.0.dylib 39 | ``` 40 | 41 | ## 2014-06-15 42 | 43 | ### git auto-save 44 | 45 | * should not allow push or pull while this is in progress -- auto save may cause a git GC, which could change pack files and confuse things (but maybe it would be OK; I don't really know), and push / pull may interfere with commit 46 | 47 | * can in principle continue to accept OT ops, but if we truncate the server's op list, then any "in flight" operations against older revisions won't be transformable 48 | 49 | * in practice, we probably won't throw away the ops, but instead store them, possibly after composing them; if we compose them, then we have the same problem: unable to transform in flight ops 50 | 51 | * pausing editing for auto-saves seems undesirable, so we probably shouldn't clear the op log on auto-save; the clearing can be a separate process that truncates at a fixed limit set to keep memory use under control 52 | 53 | * we do need to be able to work out whether there are changes, but the safest way to do this is to update the work dir and check; we could instead remember the last revision number saved to git, but there may be ops that cancel each other out, so the auto save would have nothing to do anyway 54 | 55 | ### git pull 56 | 57 | * must git auto-save first 58 | 59 | * can allow edits to continue during the pull 60 | 61 | ### git push 62 | 63 | * the main idea is to reject the push if the merge is non-trivial; this puts the burden of handling merge conflicts on the git user 64 | 65 | * the strictest way to define a "non-trivial" merge is to reject any push that isn't a fast forward; this conceptually simple, but the user experience isn't great 66 | * there's a receive.denyNonFastForwards config option that tells git-receive-pack not to allow forced pushes, so we can set that; on the other hand, we could allow forced pushes if that's what people decide they want, provided we can bound the damage that it can do to the editor 67 | * the timings would be: 68 | * we can tell when a client is initiating a push by looking for a request to info/refs with a service=receive-pack parameter 69 | * lock the web interface for the whole project, because we don't know which files will be updated, and we don't want to lose edits 70 | * do an auto-save to ensure that the latest content is committed, so HEAD is up to date 71 | * handle the push in the usual way 72 | * wait for the push to finish 73 | * then we can unlock the web interface for the project 74 | * for the git user, this could be annoying, because if I try to push while someone is actively typing, my push will get rejected, and then I have to go through a whole git pull --rebase cycle before I can try again; by this time, there may well be more minor edits, so I'd get rejected again, etc.; on the other hand, documents tend to be edited sporadically, so conficts are not all that likely, so this might not be as bad as it sounds 75 | * for the web user, it's annoying to lock the whole project, because there might not actually be any conflict 76 | * we could suspend edits (i.e. server receives them but holds the acknowledgements) and allow web users to keep editing and then, if it turned out that there weren't any changes to their file, just apply the edits as normal; if there were changes, we'd have to throw them out, however, which would be even more annoying; if pushes are short, then this wouldn't be too bad, but I've seen some fairly long git pushes with github and heroku (minutes), esp. when uploading large binaries. 77 | 78 | * the right way to do it (TM) is to treat the diffs from the git push as OT ops; this would look something like: 79 | * when client pushes, decide whether the latest save is "recent enough", by some measure 80 | * if it is, continue with the last save; othewrise, do an auto-save and use that instead 81 | * now check whether the push is a fast forward with respect to the latest save; if it is, accept it; otherwise, abort 82 | * once the push is finished, find diffs between the pushed version and the last save; convert them to OT ops (could use line-wise diffs or try to get character-wise diffs) and transform them against the relevant OT ops in the server's operation queue 83 | * this solves both problems 84 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # jotgit 2 | 3 | Git-backed real time collaborative editor built with meteor. 4 | 5 | Here's a quick demo: [http://youtu.be/z-_wSiGS18U](http://youtu.be/z-_wSiGS18U) 6 | 7 | The current version of jotgit is a prototype that lets you collaboratively edit Markdown files in a local git repository. Then you can save the files (with an optional commit message), and they'll be committed to the repository. 8 | 9 | ## Getting Started 10 | 11 | This assumes that you're on Linux or Mac OS X. 12 | 13 | First, you'll need to install node.js and meteorite, the package manager for meteor. The recommended way to do this is to first install the node version manager, following [these directions](https://github.com/creationix/nvm). The command will be something like: 14 | 15 | ``` 16 | curl https://raw.githubusercontent.com/creationix/nvm/v0.11.2/install.sh | bash 17 | ``` 18 | 19 | Then, restart your terminal, and run 20 | ``` 21 | nvm install 0.10 22 | ``` 23 | to install node 0.10 (the latest stable release, at the time of writing). 24 | 25 | Install meteorite globally (via the node package manager, npm) with 26 | ``` 27 | npm install -g meteorite 28 | ``` 29 | 30 | If you have not yet installed Meteor, do that: 31 | ``` 32 | curl https://install.meteor.com | /bin/sh 33 | ``` 34 | 35 | Clone this repository: 36 | ``` 37 | git clone https://github.com/jdleesmiller/jotgit.git 38 | ``` 39 | 40 | Start up meteor with meteorite: 41 | ``` 42 | cd jotgit # or wherever you cloned it 43 | mrt 44 | ``` 45 | It should pull in all of the required dependencies. Make some tea. 46 | 47 | Then visit [localhost:3000](http://localhost:3000) in your browser. Be sure to try with multiple windows! 48 | 49 | By default, it loads up the test repository in `tests/demo`. To point it at another repository, you can either edit `server/jotgit.coffee` or use [meteor settings](http://docs.meteor.com/#meteor_settings) to specify an alternative `projectPath`. 50 | 51 | ## About 52 | 53 | Jotgit ... 54 | 55 | * is written in [CoffeeScript](http://coffeescript.org/), a language that feels a bit like Python and compiles to JavaScript. 56 | 57 | * is built with [Meteor](https://www.meteor.com/), which is an up-and-comping web framework for real time web apps. It is remarkably developer-friendly, and it already has [very good documentation](http://docs.meteor.com/). 58 | 59 | * is powered by [operational transformation](http://en.wikipedia.org/wiki/Operational_transformation), and in particular the excellent [ot.js](https://github.com/operational-transformation/ot.js) implementation. 60 | 61 | * uses [CodeMirror](http://codemirror.net/) for the editing component. 62 | 63 | The code is structured in the usual way for a meteor app: the files in `server` run on the server, the files in `client` run on the client, and the files in `lib` run on both. There's also a `jotgit-core` package in `packages/jotgit-core` that contains most of the core classes (stuff that is not very meteor-specific). 64 | 65 | ## Roadmap 66 | 67 | * allow remote pushes to the repository (mostly done, but still need to notify web clients after a push) 68 | 69 | * auto-saves 70 | 71 | * multiple projects 72 | 73 | * user accounts 74 | 75 | * some way of handling multiple commit authors (apparently not supported by git) 76 | 77 | * option to commit to github instead of a local git repo 78 | 79 | * file type handling (various text files, binary files) 80 | 81 | * file uploads 82 | 83 | ## License 84 | 85 | MIT --- see LICENSE file. 86 | 87 | -------------------------------------------------------------------------------- /client/jotgit.coffee: -------------------------------------------------------------------------------- 1 | @Jotgit ||= {} 2 | 3 | @Files = new Meteor.Collection('files') 4 | 5 | @FileInfo = new Meteor.Collection('fileInfo') 6 | 7 | @FileOperations = new Meteor.Collection('fileOperations') 8 | 9 | @FileSelections = new Meteor.Collection('fileSelections') 10 | 11 | Template.files.files = -> Files.find() 12 | 13 | Template.files.events( 14 | 'click a.createFile': -> 15 | $('a.createFile').text('choose new file name...') 16 | filename = prompt('Please enter a new file name:') 17 | if filename == null 18 | filename = 'unnamed.md' 19 | 20 | filename = verifyFileName(filename) 21 | 22 | Meteor.call 'createFile', filename, (error, result) -> 23 | $('a.createFile').text('Create new file') 24 | alert(result) if result != 'success' 25 | false 26 | ) 27 | 28 | Template.fileEdit.fileInfo = -> FileInfo.findOne() 29 | 30 | autoSaveTimer = null 31 | 32 | Template.fileEdit.events( 33 | 'click div.btn-group.btn-toggle': -> 34 | $('.btn-toggle').children('.btn').toggleClass "active" 35 | .toggleClass "btn-primary" 36 | if $('#timer-on-button').hasClass("active") 37 | $('#timer-settings').css('display': 'inline-block') 38 | timeInMillisecs = getTimerMillis() 39 | if timeInMillisecs 40 | autoSaveTimer = Meteor.setInterval(commit, timeInMillisecs) 41 | else 42 | setTimer(5) 43 | autoSaveTimer = Meteor.setInterval(commit, 300000) 44 | else 45 | $('#timer-settings').hide() 46 | Meteor.clearInterval autoSaveTimer 47 | false 48 | ) 49 | 50 | Template.fileEdit.events( 51 | 'change #autosave-time': -> 52 | timeInMillisecs = getTimerMillis() 53 | if timeInMillisecs 54 | Meteor.clearInterval autoSaveTimer 55 | autoSaveTimer = Meteor.setInterval(commit, timeInMillisecs) 56 | else 57 | console.log 'invalid timer input' 58 | false 59 | ) 60 | 61 | Template.fileEdit.events( 62 | 'click a.commit': -> 63 | $('a.commit').text('saving...') 64 | message = prompt('Name for this save (optional):') 65 | if message == null 66 | $('a.commit').text('save project') 67 | else 68 | commit message 69 | false 70 | ) 71 | 72 | Template.fileEdit.events( 73 | 'click #file-name': -> 74 | $("#file-name").hide() 75 | $("#rename-form").css('display': 'inline-block') 76 | false 77 | ) 78 | 79 | Template.fileEdit.events( 80 | 'submit #rename-form': -> 81 | filename = verifyFileName($("#new-file-name").val()) 82 | if filename == ".md" 83 | alert('enter a valid name') 84 | else 85 | Meteor.call 'renameFile', fileInfo._id, filename, (error, result) -> 86 | alert(result) if result != 'success' 87 | $("#rename-form").hide() 88 | $("#file-name").css('display': 'inline-block') 89 | false 90 | ) 91 | 92 | # note: this isn't called when switching between files 93 | Template.fileEdit.rendered = -> 94 | Jotgit.cm = CodeMirror.fromTextArea(editor, 95 | lineNumbers: true 96 | ) 97 | Jotgit.cmAdapter = new ot.CodeMirrorAdapter(Jotgit.cm) 98 | 99 | # note: this isn't called when switching between files 100 | Template.fileEdit.destroyed = -> 101 | if Jotgit.cm 102 | $(Jotgit.cm.getWrapperElement()).remove() 103 | delete Jotgit.cm 104 | 105 | class MeteorServerAdapter 106 | constructor: (@filePath) -> 107 | 108 | sendOperation: (revision, operation, selection) -> 109 | self = this 110 | 111 | Meteor.call('sendOperation', @filePath, revision, operation, 112 | selection, -> self.trigger('ack')) 113 | 114 | sendSelection: (selection) -> 115 | Meteor.call('sendSelection', @filePath, selection) 116 | 117 | registerCallbacks: (cb) -> @callbacks = cb 118 | 119 | trigger: (event) -> 120 | action = this.callbacks && this.callbacks[event] 121 | action.apply(this, Array.prototype.slice.call(arguments, 1)) if action 122 | 123 | commit = (message = 'Autosave') -> 124 | console.log 'committing...' 125 | $('a.commit').text('saving...') 126 | Meteor.call 'commit', message, (error, result) -> 127 | $('a.commit').text('save project') 128 | alert(result) if result != 'success' 129 | 130 | getTimerMillis =-> 131 | timeInMinutes = parseFloat($('#autosave-time').val()) 132 | if timeInMinutes.isNan 133 | return false 134 | else 135 | return timeInMinutes * 60000 136 | 137 | setTimer = (time) -> 138 | $('#autosave-time').val(time) 139 | 140 | verifyFileName = (filename) -> 141 | sublist = filename.split('.') 142 | if sublist.length == 1 || sublist[sublist.length-1] != 'md' 143 | filename += '.md' 144 | 145 | #precondition: filename ends on '.md' 146 | while Files.findOne({path: filename}) 147 | filename = filename.substring(0, filename.length-3) + '-1.md' 148 | #postcondition: filename is unique 149 | return filename 150 | 151 | 152 | Deps.autorun -> 153 | fileInfo = FileInfo.findOne() 154 | if fileInfo 155 | # TODO is Jotgit.cm guaranteed to be set here? looks like no 156 | try 157 | Jotgit.cmAdapter.ignoreNextChange = true 158 | Jotgit.cm.setValue fileInfo.document 159 | finally 160 | Jotgit.cmAdapter.ignoreNextChange = false 161 | Jotgit.cm.focus() 162 | # TODO are we going to handle clients ourselves? 163 | clients = [] 164 | serverAdapter = new MeteorServerAdapter(fileInfo._id) 165 | Jotgit.editorClient = new ot.EditorClient(fileInfo.revision, clients, 166 | serverAdapter, Jotgit.cmAdapter) 167 | 168 | Meteor.subscribe('fileOperations', fileInfo._id) 169 | Meteor.subscribe('fileSelections', fileInfo._id) 170 | 171 | lastRevision = 0 172 | Deps.autorun -> 173 | operations = FileOperations.find( 174 | {_id: {$gt: lastRevision}}, sort: {_id: 1}) 175 | operations.forEach (operation) -> 176 | lastRevision = operation._id 177 | if operation.clientId != fileInfo.clientId 178 | serverAdapter.trigger('operation', operation.operation) 179 | serverAdapter.trigger('selection', 180 | operation.clientId, operation.selection) 181 | 182 | Deps.autorun -> 183 | selections = FileSelections.findOne() 184 | for clientId, selection of selections 185 | serverAdapter.trigger('selection', clientId, selection) 186 | 187 | -------------------------------------------------------------------------------- /client/jotgit.css: -------------------------------------------------------------------------------- 1 | /* CSS declarations go here */ 2 | .cm-header-1 { 3 | font-size: xx-large; 4 | } 5 | 6 | .cm-header-2 { 7 | font-size: x-large; 8 | } 9 | 10 | .cm-header-3 { 11 | font-size: large; 12 | } 13 | 14 | img#logo { 15 | padding-top: 20px; 16 | width: 100px; 17 | } 18 | 19 | #timer-settings { 20 | display: none; 21 | } 22 | 23 | input#autosave-time { 24 | vertical-align: top; 25 | margin-bottom: 0px; 26 | } 27 | 28 | form#rename-form { 29 | display: none; 30 | margin-bottom: 0px; 31 | } 32 | 33 | input#new-file-name { 34 | font-size: large; 35 | height: 23px; 36 | vertical-align: middle; 37 | margin-bottom: 0px; 38 | } -------------------------------------------------------------------------------- /client/jotgit.jade: -------------------------------------------------------------------------------- 1 | template(name="home") 2 | div.container 3 | div.row 4 | div.col-md-11 5 | h1 jotgit 6 | h2 files 7 | +files 8 | div.col-md-1 9 | img#logo(src='/images/logos.png') 10 | 11 | template(name="files") 12 | ul 13 | each files 14 | li 15 | a(href="{{pathFor 'fileEdit' path}}") {{path}} 16 | a.createFile(href='#') Create new file 17 | 18 | template(name="fileEdit") 19 | div.container 20 | div.row 21 | div.col-md-11 22 | h1 jotgit 23 | a.commit(href='#') save project 24 | div.col-md-1 25 | img#logo(src='/images/logos.png') 26 | div.row 27 | div.col-md-4 28 | h2 files 29 | +files 30 | div.row 31 | div.col-md-12 32 | span Auto-save 33 | div.btn-group.btn-toggle 34 | button.btn.btn-xs(id="timer-on-button") On 35 | button.btn.btn-xs.btn-primary.active(id="timer-off-button") Off 36 | div(id="timer-settings") 37 | span every 38 | input(id='autosave-time', type="text") 39 | | minutes 40 | 41 | div.row 42 | div.col-md-8 43 | h3(id="file-name") {{fileInfo._id}} 44 | form(id="rename-form") 45 | h3 46 | input(id='new-file-name', type="text") 47 | input(type="submit", value="Rename", class='btn btn-xs') 48 | textarea#editor 49 | -------------------------------------------------------------------------------- /lib/jotgit.coffee: -------------------------------------------------------------------------------- 1 | Router.map -> 2 | this.route 'home', 3 | path: '/' 4 | waitOn: -> Meteor.subscribe('files') 5 | 6 | this.route 'fileEdit', 7 | path: '/files/:path' 8 | waitOn: -> 9 | [Meteor.subscribe('files'), 10 | Meteor.subscribe('fileInfo', this.params.path)] 11 | -------------------------------------------------------------------------------- /packages/.gitignore: -------------------------------------------------------------------------------- 1 | /bower 2 | /npm 3 | /iron-router 4 | /blaze-layout 5 | /jade 6 | /bootstrap-3 7 | /blueimp-file-upload 8 | -------------------------------------------------------------------------------- /packages/chokidar/.gitignore: -------------------------------------------------------------------------------- 1 | .build* 2 | -------------------------------------------------------------------------------- /packages/chokidar/.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /packages/chokidar/.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /packages/chokidar/.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "chokidar": { 4 | "version": "0.8.2", 5 | "dependencies": { 6 | "fsevents": { 7 | "version": "0.2.0", 8 | "dependencies": { 9 | "nan": { 10 | "version": "0.8.0" 11 | } 12 | } 13 | }, 14 | "recursive-readdir": { 15 | "version": "0.0.2" 16 | } 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/chokidar/chokidar.js: -------------------------------------------------------------------------------- 1 | Chokidar = Npm.require('chokidar'); 2 | -------------------------------------------------------------------------------- /packages/chokidar/package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ summary: "Chokidar" }); 2 | 3 | Npm.depends({ "chokidar": "0.8.2" }); 4 | 5 | Package.on_use(function (api) { 6 | api.export('Chokidar'); 7 | api.add_files('chokidar.js', 'server'); 8 | }); 9 | -------------------------------------------------------------------------------- /packages/jotgit-core/.gitignore: -------------------------------------------------------------------------------- 1 | .build* 2 | -------------------------------------------------------------------------------- /packages/jotgit-core/.npm/package/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /packages/jotgit-core/.npm/package/README: -------------------------------------------------------------------------------- 1 | This directory and the files immediately inside it are automatically generated 2 | when you change this package's NPM dependencies. Commit the files in this 3 | directory (npm-shrinkwrap.json, .gitignore, and this README) to source control 4 | so that others run the same versions of sub-dependencies. 5 | 6 | You should NOT check in the node_modules directory that Meteor automatically 7 | creates; if you are using git, the .gitignore file tells git to ignore it. 8 | -------------------------------------------------------------------------------- /packages/jotgit-core/.npm/package/npm-shrinkwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "chokidar": { 4 | "version": "0.8.2", 5 | "dependencies": { 6 | "fsevents": { 7 | "version": "0.2.0", 8 | "dependencies": { 9 | "nan": { 10 | "version": "0.8.0" 11 | } 12 | } 13 | }, 14 | "recursive-readdir": { 15 | "version": "0.0.2" 16 | } 17 | } 18 | }, 19 | "ot": { 20 | "version": "https://github.com/Operational-Transformation/ot.js/tarball/3ab1be8efadd64141e3c13ca4e02325d0331882f" 21 | }, 22 | "shelljs": { 23 | "version": "0.3.0" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/jotgit-core/client.js: -------------------------------------------------------------------------------- 1 | // make the "ot" in the package the same as the global "this.ot"; this is 2 | // required to avoid issues with require()s in the ot.js client source 3 | this.ot = ot = {}; 4 | -------------------------------------------------------------------------------- /packages/jotgit-core/editor-server.coffee: -------------------------------------------------------------------------------- 1 | EventEmitter = Npm.require('events').EventEmitter 2 | ot = Npm.require('ot') 3 | 4 | # ot doesn't export this by default 5 | ot.WrappedOperation = Npm.require( 6 | './npm/jotgit-core/main/node_modules/ot/lib/wrapped-operation.js') 7 | 8 | # 9 | # Wrapper around ot.js's ot.Server that handles serialisation and emits events 10 | # that we consume in the publish functions that send transformed operations to 11 | # all of the clients. 12 | # 13 | class EditorServer extends EventEmitter 14 | constructor: (document, operations=[]) -> 15 | @server = new ot.Server(document, operations) 16 | @clientSelections = {} 17 | 18 | document: -> @server.document 19 | operations: -> @server.operations 20 | revision: -> @server.operations.length 21 | 22 | # this is used to 'catch up' when a client first connects, because there may 23 | # be a delay between when the client receives the latest version upon 24 | # connecting and when it starts listening for further edits 25 | emitOperationsAfter: (startRevision) -> 26 | clientId = null 27 | selection = null 28 | for operation, revision in @operations().slice(startRevision) 29 | this.emit 'operationApplied', clientId, revision + startRevision, 30 | operation.wrapped.toJSON(), selection 31 | 32 | receiveOperation: (clientId, revision, operation, selection) -> 33 | wrapped = new ot.WrappedOperation( 34 | ot.TextOperation.fromJSON(operation), 35 | selection && ot.Selection.fromJSON(selection) 36 | ) 37 | 38 | wrappedPrime = @server.receiveOperation(revision, wrapped) 39 | selectionPrime = wrappedPrime.meta 40 | @updateSelection(clientId, selectionPrime) 41 | 42 | this.emit 'operationApplied', 43 | clientId, @revision(), wrappedPrime.wrapped.toJSON(), selectionPrime 44 | 45 | null 46 | 47 | updateSelection: (clientId, selection) -> 48 | if selection 49 | @clientSelections[clientId] = ot.Selection.fromJSON(selection) 50 | else 51 | delete @clientSelections[clientId] 52 | 53 | this.emit 'selectionsUpdated', @clientSelections 54 | 55 | null 56 | 57 | Jotgit.EditorServer = EditorServer 58 | -------------------------------------------------------------------------------- /packages/jotgit-core/jotgit.coffee: -------------------------------------------------------------------------------- 1 | Jotgit = {} 2 | -------------------------------------------------------------------------------- /packages/jotgit-core/package.js: -------------------------------------------------------------------------------- 1 | Package.describe({ summary: "jotgit-core" }); 2 | 3 | // note: there is a bug in ot 0.0.14 that's fixed in master, but meteor 4 | // currently requires us to specify a particular commit here 5 | Npm.depends({ 6 | "chokidar": "0.8.2", 7 | "shelljs": "0.3.0", 8 | "ot": "https://github.com/Operational-Transformation/ot.js/tarball/3ab1be8efadd64141e3c13ca4e02325d0331882f" 9 | }); 10 | 11 | Package.on_use(function (api) { 12 | api.use('coffeescript'); 13 | api.export('Jotgit'); 14 | api.export('ot'); 15 | 16 | api.add_files([ 17 | 'jotgit.coffee', 18 | 'repo.coffee', 19 | 'editor-server.coffee'], 'server'); 20 | 21 | var otPath = '.npm/package/node_modules/ot/lib/'; 22 | 23 | api.add_files([ 24 | 'client.js', 25 | otPath + 'text-operation.js', 26 | otPath + 'selection.js', 27 | otPath + 'wrapped-operation.js', 28 | otPath + 'undo-manager.js', 29 | otPath + 'client.js', 30 | otPath + 'codemirror-adapter.js', 31 | otPath + 'editor-client.js'], ['client'], {bare: true}); 32 | }); 33 | 34 | -------------------------------------------------------------------------------- /packages/jotgit-core/repo.coffee: -------------------------------------------------------------------------------- 1 | EventEmitter = Npm.require('events').EventEmitter 2 | Path = Npm.require('path') 3 | fs = Npm.require('fs') 4 | Future = Npm.require('fibers/future') 5 | spawn = Npm.require('child_process').spawn 6 | shelljs = Npm.require('shelljs') 7 | chokidar = Npm.require('chokidar') 8 | zlib = Npm.require('zlib') 9 | 10 | syncExec = Meteor._wrapAsync(shelljs.exec) 11 | 12 | # 13 | # Very simple interface to a git repository. 14 | # 15 | # We use the working copy to 16 | # 17 | # 1) list the files in the project 18 | # 19 | # 2) read file content when initialising the server 20 | # 21 | # 3) write file content before committing it 22 | # 23 | # This approach lets us use the standard git commands to commit. 24 | # 25 | # We also watch for files to be added or removed from the working copy (that's 26 | # what "chokidar" does). This isn't really used yet, but I think it may be when 27 | # we get to accepting git pushes. For now, it's just fun to add a file and watch 28 | # it show up in the web interface. 29 | # 30 | class Repo extends EventEmitter 31 | constructor: (@repoPath) -> 32 | self = this 33 | 34 | # note: man git-init(1) says it is OK to run init more than once 35 | unless fs.existsSync(Path.join(@repoPath, '.git')) 36 | @runInRepoPath 'git', ['init'] 37 | 38 | # this config option is required in order to accept a git push even though 39 | # we have a working copy checked out; this won't be necessary when we use 40 | # the bare repo instead 41 | @runInRepoPath 'git', ['config', 'receive.denyCurrentBranch', 'ignore'] 42 | 43 | watcher = chokidar.watch(@repoPath, 44 | ignored: /\/\.git/, 45 | ignoreInitial: true) 46 | 47 | watcher.on 'add', 48 | (path) -> self.emit('added', self.relativePath(path)) 49 | 50 | watcher.on 'unlink', 51 | (path) -> self.emit('removed', self.relativePath(path)) 52 | 53 | watcher.on 'error', (error) -> console.log(error) 54 | 55 | entries: -> 56 | self = this 57 | paths = syncExec( 58 | "find #{@repoPath} -not -iwholename '*/.git*'", silent: true) 59 | paths.trim().split("\n").splice(1).map((path) -> self.relativePath(path)) 60 | 61 | # beware directory traversal attacks 62 | checkPath: (path) -> 63 | resolvedPath = Path.join(@repoPath, path) 64 | if resolvedPath.indexOf(@repoPath) != 0 65 | throw new Error("path #{path} outside of repo") 66 | resolvedPath 67 | 68 | relativePath: (path) -> 69 | @checkPath(path) 70 | Path.relative(@repoPath, path) 71 | 72 | readFile: (path) -> 73 | absolutePath = @checkPath(path) 74 | fs.readFileSync(absolutePath, encoding: 'utf8') 75 | 76 | streamFile: (path, output) -> 77 | absolutePath = @checkPath(path) 78 | future = new Future() 79 | input = fs.createReadStream(absolutePath) 80 | input.on 'error', (err) -> future.throw(err) 81 | input.on 'close', () -> future.return() 82 | input.pipe(output) 83 | future.wait() 84 | 85 | gzipStreamFile: (path, output) -> 86 | future = new Future() 87 | gzip = zlib.createGzip() 88 | gzip.on 'error', (err) -> future.throw(err) 89 | gzip.on 'close', () -> future.return() 90 | gzip.pipe(output) 91 | @streamFile(path, gzip) 92 | future.wait() 93 | 94 | writeFile: (path, data) -> 95 | absolutePath = @checkPath(path) 96 | fs.writeFileSync(absolutePath, data, encoding: 'utf8') 97 | 98 | createFile: (path) -> 99 | absolutePath = @checkPath(path) 100 | fs.writeFileSync(absolutePath, '', encoding: 'utf8') 101 | 102 | renameFile: (oldPath, newPath) -> 103 | absoluteOldPath = @checkPath(oldPath) 104 | absoluteNewPath = @checkPath(newPath) 105 | fs.renameSync(absoluteOldPath, absoluteNewPath) 106 | 107 | spawnInRepoPath: (command, args=[], options={}) -> 108 | options.cwd = @repoPath 109 | spawn(command, args, options) 110 | 111 | waitOnSpawn: (child) -> 112 | future = new Future() 113 | child.on 'close', (code, signal) -> 114 | future.return(code: code, signal: signal) 115 | child.on 'error', (err) -> 116 | future.throw(err) 117 | future.wait() 118 | 119 | runInRepoPath: (command, args=[], options={}) -> 120 | options.stdio ||= ['ignore', 1, 2] # echo output to server logs 121 | child = @spawnInRepoPath(command, args, options) 122 | @waitOnSpawn(child) 123 | 124 | commit: (message) -> 125 | message ||= 'saved' 126 | addResult = @runInRepoPath('git', ['add', '.']) 127 | console.log addResult 128 | if addResult.code == 0 129 | commitResult = @runInRepoPath('git', ['commit', '--message', message]) 130 | console.log commitResult 131 | if commitResult.code == 0 132 | 'success' 133 | else if commitResult.code == 1 134 | 'no changes' 135 | else 136 | 'commit failed' 137 | else 138 | 'add failed' # not sure what would cause this to fail 139 | 140 | setNoCacheHeaders: (response) -> 141 | response.setHeader 'Expires', 'Fri, 01 Jan 1980 00:00:00 GMT' 142 | response.setHeader 'Pragma', 'no-cache' 143 | response.setHeader 'Cache-Control', 'no-cache, max-age=0, must-revalidate' 144 | 145 | gitPacket: (packet) -> 146 | # prefix packet with 4-digit zero-padded length in hexadecimal 147 | length = "0000#{(packet.length + 4).toString(16)}".slice(-4) 148 | "#{length}#{packet}" 149 | 150 | getFromService: (response, service) -> 151 | @setNoCacheHeaders response 152 | response.setHeader 'Content-Type', "application/x-#{service}-advertisement" 153 | 154 | response.write @gitPacket("# service=#{service}\n") + "0000" 155 | 156 | child = @spawnInRepoPath( 157 | service, ['--stateless-rpc', '--advertise-refs', '.'], 158 | stdio: ['ignore', 'pipe', 2]) 159 | child.stdout.pipe response 160 | @waitOnSpawn(child) 161 | 162 | postToService: (request, response, service) -> 163 | @setNoCacheHeaders response 164 | response.setHeader 'Content-Type', "application/x-#{service}-result" 165 | 166 | child = @spawnInRepoPath( 167 | service, ['--stateless-rpc', '.'], 168 | stdio: ['pipe', 'pipe', 2]) 169 | request.pipe child.stdin 170 | child.stdout.pipe response 171 | @waitOnSpawn(child) 172 | 173 | resetHard: -> 174 | @runInRepoPath 'git', ['reset', '--hard'] 175 | 176 | Jotgit.Repo = Repo 177 | -------------------------------------------------------------------------------- /public/images/logos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdleesmiller/jotgit/2520464848db551a3d189259913afa5c8053cabc/public/images/logos.png -------------------------------------------------------------------------------- /server/jotgit.coffee: -------------------------------------------------------------------------------- 1 | path = Npm.require('path') 2 | http = Npm.require('http') 3 | 4 | Meteor.startup -> 5 | 6 | Meteor.settings.projectPath ||= path.join(process.env.PWD, 'tests/demo') 7 | 8 | repo = new Jotgit.Repo(Meteor.settings.projectPath) 9 | 10 | editorServers = {} 11 | 12 | Meteor.publish 'files', -> 13 | self = this 14 | 15 | handleAdded = (path) -> self.added('files', path, {path: path}) 16 | repo.addListener 'added', handleAdded 17 | 18 | handleRemoved = (path) -> self.removed('files', path) 19 | repo.addListener 'removed', handleRemoved 20 | 21 | self.added('files', path, {path: path}) for path in repo.entries() 22 | 23 | self.onStop -> 24 | repo.removeListener 'added', handleAdded 25 | repo.removeListener 'removed', handleRemoved 26 | 27 | self.ready() 28 | 29 | Meteor.publish 'fileInfo', (filePath) -> 30 | self = this 31 | 32 | server = editorServers[filePath] ||= new Jotgit.EditorServer( 33 | repo.readFile(filePath)) 34 | 35 | self.added 'fileInfo', filePath, 36 | clientId: this.connection.id 37 | revision: server.revision() 38 | document: server.document() 39 | 40 | self.ready() 41 | 42 | Meteor.publish 'fileOperations', (filePath, startRevision) -> 43 | self = this 44 | 45 | server = editorServers[filePath] 46 | throw new Error("no server for #{filePath}") unless server 47 | 48 | handleOperationApplied = (clientId, revision, operationJson, selection) -> 49 | console.log ['op applied', operationJson] 50 | self.added 'fileOperations', revision, 51 | clientId: clientId 52 | operation: operationJson 53 | selection: selection 54 | server.addListener 'operationApplied', handleOperationApplied 55 | 56 | server.emitOperationsAfter startRevision 57 | 58 | self.onStop -> 59 | server.removeListener 'operationApplied', handleOperationApplied 60 | 61 | self.ready() 62 | 63 | Meteor.publish 'fileSelections', (filePath) -> 64 | self = this 65 | 66 | # TODO we never remove clients at the moment, so their selections are 67 | # immortal; we should be handling client disconnections 68 | 69 | server = editorServers[filePath] 70 | throw new Error("no server for #{filePath}") unless server 71 | 72 | self.added 'fileSelections', filePath, {} 73 | 74 | handleSelectionsUpdated = (selections) -> 75 | self.changed 'fileSelections', filePath, selections 76 | server.addListener 'selectionsUpdated', handleSelectionsUpdated 77 | 78 | self.onStop -> 79 | server.removeListener 'selectionsUpdated', handleSelectionsUpdated 80 | 81 | self.ready() 82 | 83 | Meteor.methods( 84 | sendOperation: (filePath, revision, operation, selection) -> 85 | try 86 | server = editorServers[filePath] 87 | throw new Error("no server for #{filePath}") unless server 88 | 89 | clientId = this.connection.id 90 | server.receiveOperation(clientId, revision, operation, selection) 91 | 92 | 'ack' 93 | catch error 94 | console.log error 95 | console.log error.stack 96 | 'fail' # TODO the client doesn't trap this, but it could do 97 | 98 | sendSelection: (filePath, selection) -> 99 | server = editorServers[filePath] 100 | throw new Error("no server for #{filePath}") unless server 101 | 102 | clientId = this.connection.id 103 | server.updateSelection(clientId, selection) 104 | 105 | commit: (message) -> 106 | for filePath, server of editorServers 107 | repo.writeFile filePath, server.document() 108 | result = repo.commit(message) 109 | result 110 | 111 | createFile: (fileName) -> 112 | repo.createFile fileName 113 | 'success' 114 | ) 115 | 116 | checkMethod = (request, response, allowedMethod) -> 117 | if request.method == allowedMethod 118 | return true 119 | else 120 | response.statusCode = 405 121 | response.setHeader 'Allow', allowedMethod 122 | response.write "405 Method Not Allowed\n" 123 | return false 124 | 125 | # 126 | # git push and pull using the 'smart' protocol 127 | # 128 | # This is based on "Implementing a Git HTTP Server" by Michael F. Collins, III 129 | # http://www.michaelfcollins3.me/blog/2012/05/18/implementing-a-git-http-server.html 130 | # 131 | # Some other libraries that may be helpful here: 132 | # https://github.com/substack/pushover 133 | # https://github.com/schacon/grack 134 | # 135 | Router.map -> 136 | @route 'projectGitInfoRefs', 137 | path: '/project.git/info/refs' 138 | where: 'server' 139 | action: -> 140 | return unless checkMethod(@request, @response, 'GET') 141 | service = @request.query.service 142 | if service == 'git-receive-pack' || service == 'git-upload-pack' 143 | repo.getFromService(@response, service) 144 | else 145 | @response.statusCode = 500 # TODO do something else here? 146 | @response.write "500 Internal Server Error\n" 147 | 148 | @route 'projectGitReceivePack', 149 | path: '/project.git/git-receive-pack' 150 | where: 'server' 151 | action: -> 152 | return unless checkMethod(@request, @response, 'POST') 153 | repo.postToService(@request, @response, 'git-receive-pack') 154 | repo.resetHard() 155 | 156 | @route 'projectGitReceivePack', 157 | path: '/project.git/git-upload-pack' 158 | where: 'server' 159 | action: -> 160 | return unless checkMethod(@request, @response, 'POST') 161 | repo.postToService(@request, @response, 'git-upload-pack') 162 | -------------------------------------------------------------------------------- /smart.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | "bower": { 4 | "git": "https://github.com/tvararu/meteor-bower.git", 5 | "branch": "master" 6 | }, 7 | "iron-router": {}, 8 | "jade": {} 9 | }, 10 | "bower": { 11 | "codemirror": { 12 | "version": "4.1.0", 13 | "additionalFiles": [ 14 | "mode/markdown/markdown.js" 15 | ] 16 | }, 17 | "bootstrap": { 18 | "version": "3.2.0" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /smart.lock: -------------------------------------------------------------------------------- 1 | { 2 | "meteor": {}, 3 | "dependencies": { 4 | "basePackages": { 5 | "bower": { 6 | "git": "https://github.com/tvararu/meteor-bower.git", 7 | "branch": "master" 8 | }, 9 | "iron-router": {}, 10 | "jade": {} 11 | }, 12 | "packages": { 13 | "bower": { 14 | "git": "https://github.com/tvararu/meteor-bower.git", 15 | "branch": "master", 16 | "commit": "9e30f4f960a5b61afea93e52c6ca71c2f9e33256" 17 | }, 18 | "iron-router": { 19 | "git": "https://github.com/EventedMind/iron-router.git", 20 | "tag": "v0.7.1", 21 | "commit": "d1ffb3f06ea4c112132b030f2eb1a70b81675ecb" 22 | }, 23 | "jade": { 24 | "git": "https://github.com/mquandalle/meteor-jade.git", 25 | "tag": "v0.2.4", 26 | "commit": "a47a4c59daaa57e7fe5f1544d4ea76d95a5c9abf" 27 | }, 28 | "blaze-layout": { 29 | "git": "https://github.com/EventedMind/blaze-layout.git", 30 | "tag": "v0.2.4", 31 | "commit": "b40e9b0612329288d75cf52ad14a7da64bb8618f" 32 | } 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/demo/article.md: -------------------------------------------------------------------------------- 1 | # Test Article 2 | 3 | ## Abstract 4 | 5 | Here is a *short* test article. 6 | -------------------------------------------------------------------------------- /tests/demo/chapter1.md: -------------------------------------------------------------------------------- 1 | # Chapter 1 2 | 3 | Once upon a time there was an application called jotgit. And it lived happily 4 | ever after. I guess I shouldn't quit my day job. 5 | --------------------------------------------------------------------------------