├── .gitignore ├── .jshintrc ├── .rspec ├── .ruby-version ├── .travis.yml ├── Gemfile ├── Gemfile.lock ├── README.md ├── Rakefile ├── app ├── assets │ ├── images │ │ └── .keep │ ├── javascripts │ │ ├── application.js │ │ ├── collections │ │ │ └── items.js │ │ ├── models │ │ │ └── item.js │ │ ├── routers │ │ │ └── router.js │ │ ├── views │ │ │ ├── items │ │ │ │ ├── index_view.js │ │ │ │ ├── item_view.js │ │ │ │ ├── item_view_events.js │ │ │ │ ├── list_view.js │ │ │ │ └── show_view.js │ │ │ ├── saved_view.js │ │ │ └── undo_view.js │ │ └── workflowy.js │ ├── stylesheets │ │ ├── application.css │ │ ├── items.css.scss │ │ ├── nav.css.scss │ │ ├── reset.css │ │ ├── root.css.scss │ │ ├── sessions.css.scss │ │ ├── shares.css.scss │ │ ├── users.css.scss │ │ └── utility.css.scss │ └── templates │ │ └── items │ │ ├── index.jst.ejs │ │ ├── item.jst.ejs │ │ └── show.jst.ejs ├── controllers │ ├── application_controller.rb │ ├── concerns │ │ └── .keep │ ├── items_controller.rb │ ├── root_controller.rb │ ├── sessions_controller.rb │ ├── shares_controller.rb │ └── users_controller.rb ├── helpers │ ├── application_helper.rb │ ├── items_helper.rb │ ├── root_helper.rb │ ├── sessions_helper.rb │ ├── shares_helper.rb │ └── users_helper.rb ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ ├── .keep │ │ └── seed_demo_items.rb │ ├── item.rb │ ├── share.rb │ ├── user.rb │ └── view.rb └── views │ ├── _view.html.erb │ ├── items │ ├── _form.html.erb │ ├── _item.html.erb │ ├── index.html.erb │ └── show.html.erb │ ├── layouts │ └── application.html.erb │ ├── root │ ├── home.html.erb │ └── index.html.erb │ ├── sessions │ ├── _form.html.erb │ └── new.html.erb │ ├── shares │ ├── _share.html.erb │ ├── index.html.erb │ └── show.html.erb │ └── users │ ├── _form.html.erb │ └── new.html.erb ├── bin ├── bundle ├── rails ├── rake └── spring ├── config.ru ├── config ├── application.rb ├── boot.rb ├── database.yml ├── database.yml.travis ├── environment.rb ├── environments │ ├── development.rb │ ├── production.rb │ └── test.rb ├── initializers │ ├── backtrace_silencers.rb │ ├── cookies_serializer.rb │ ├── filter_parameter_logging.rb │ ├── inflections.rb │ ├── mime_types.rb │ ├── omniauth.rb │ ├── session_store.rb │ └── wrap_parameters.rb ├── locales │ └── en.yml ├── newrelic.yml ├── routes.rb └── secrets.yml ├── db ├── migrate │ ├── 20140618222800_create_users.rb │ ├── 20140621025457_create_items.rb │ ├── 20140624142227_create_views.rb │ ├── 20140625132201_create_shares.rb │ ├── 20140626194425_change_items_url_to_uuid.rb │ ├── 20140627041832_add_uid_and_provider_to_users.rb │ ├── 20140629233057_allow_null_item_titles.rb │ └── 20140630232756_item_rank_deferrable.rb ├── schema.rb └── seeds.rb ├── lib ├── assets │ └── .keep └── tasks │ └── .keep ├── log └── .keep ├── public ├── 404.html ├── 422.html ├── 500.html ├── favicon.ico └── robots.txt ├── spec ├── controllers │ ├── items_controller_spec.rb │ ├── root_controller_spec.rb │ ├── sessions_controller_spec.rb │ ├── shares_controller_spec.rb │ └── users_controller_spec.rb ├── factories │ ├── items.rb │ ├── shares.rb │ ├── users.rb │ └── views.rb ├── models │ ├── item_spec.rb │ ├── share_spec.rb │ ├── user_spec.rb │ └── view_spec.rb ├── rails_helper.rb └── spec_helper.rb └── vendor └── assets ├── javascripts ├── .keep ├── jquery-ui.js └── keymaster.js └── stylesheets └── .keep /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore the default SQLite database. 11 | /db/*.sqlite3 12 | /db/*.sqlite3-journal 13 | 14 | # Ignore all logfiles and tempfiles. 15 | /log/*.log 16 | /tmp 17 | 18 | # Ignore application configuration 19 | /config/application.yml 20 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "predef": ["Workflowy", "key", "_", "Backbone", "JST"], 3 | "camelcase": true, 4 | "eqeqeq": true, 5 | "freeze": true, 6 | "immed": true, 7 | "indent": 2, 8 | "latedef": "nofunc", 9 | "newcap": true, 10 | "noarg": true, 11 | "noempty": true, 12 | "nonbsp": true, 13 | "undef": true, 14 | "unused": true, 15 | "strict": true, 16 | "maxlen": 80, 17 | 18 | "browser": true, 19 | "jquery": true 20 | } 21 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.2.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | before_script: 3 | - psql -c 'create database travis_ci_test;' -U postgres 4 | - cp config/database.yml.travis config/database.yml 5 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '4.2' 4 | gem 'pg' 5 | gem 'sass-rails', '~> 4.0.3' 6 | gem 'uglifier', '>= 1.3.0' 7 | gem 'coffee-rails', '~> 4.0.0' 8 | gem 'jquery-rails' 9 | gem 'jbuilder', '~> 2.0' 10 | gem 'sdoc', '~> 0.4.0', group: :doc 11 | gem 'spring', group: :development 12 | gem 'therubyracer' 13 | 14 | gem 'thin' 15 | 16 | gem 'bcrypt' 17 | gem 'omniauth-google-oauth2' 18 | gem 'figaro' 19 | 20 | gem 'friendly_id' 21 | 22 | gem 'backbone-on-rails' 23 | 24 | 25 | # Use Capistrano for deployment 26 | # gem 'capistrano-rails', group: :development 27 | 28 | # Use debugger 29 | # gem 'debugger', group: [:development, :test] 30 | 31 | group :development do 32 | gem 'web-console' # default in rails 4.2 33 | gem 'better_errors' 34 | gem 'binding_of_caller' 35 | gem 'pry-rails' 36 | gem 'pry-byebug' 37 | end 38 | 39 | group :development, :test do 40 | gem 'rspec-rails' 41 | gem 'factory_girl_rails' 42 | end 43 | 44 | group :test do 45 | gem 'faker' 46 | gem 'shoulda-matchers' 47 | end 48 | 49 | group :production do 50 | gem 'rails_12factor' 51 | gem 'newrelic_rpm' 52 | end 53 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actionmailer (4.2.0) 5 | actionpack (= 4.2.0) 6 | actionview (= 4.2.0) 7 | activejob (= 4.2.0) 8 | mail (~> 2.5, >= 2.5.4) 9 | rails-dom-testing (~> 1.0, >= 1.0.5) 10 | actionpack (4.2.0) 11 | actionview (= 4.2.0) 12 | activesupport (= 4.2.0) 13 | rack (~> 1.6.0) 14 | rack-test (~> 0.6.2) 15 | rails-dom-testing (~> 1.0, >= 1.0.5) 16 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 17 | actionview (4.2.0) 18 | activesupport (= 4.2.0) 19 | builder (~> 3.1) 20 | erubis (~> 2.7.0) 21 | rails-dom-testing (~> 1.0, >= 1.0.5) 22 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 23 | activejob (4.2.0) 24 | activesupport (= 4.2.0) 25 | globalid (>= 0.3.0) 26 | activemodel (4.2.0) 27 | activesupport (= 4.2.0) 28 | builder (~> 3.1) 29 | activerecord (4.2.0) 30 | activemodel (= 4.2.0) 31 | activesupport (= 4.2.0) 32 | arel (~> 6.0) 33 | activesupport (4.2.0) 34 | i18n (~> 0.7) 35 | json (~> 1.7, >= 1.7.7) 36 | minitest (~> 5.1) 37 | thread_safe (~> 0.3, >= 0.3.4) 38 | tzinfo (~> 1.1) 39 | arel (6.0.0) 40 | backbone-on-rails (1.1.2.1) 41 | eco 42 | ejs 43 | jquery-rails 44 | railties 45 | bcrypt (3.1.10) 46 | better_errors (2.1.1) 47 | coderay (>= 1.0.0) 48 | erubis (>= 2.6.6) 49 | rack (>= 0.9.0) 50 | binding_of_caller (0.7.2) 51 | debug_inspector (>= 0.0.1) 52 | builder (3.2.2) 53 | byebug (5.0.0) 54 | columnize (= 0.9.0) 55 | coderay (1.1.0) 56 | coffee-rails (4.0.1) 57 | coffee-script (>= 2.2.0) 58 | railties (>= 4.0.0, < 5.0) 59 | coffee-script (2.4.1) 60 | coffee-script-source 61 | execjs 62 | coffee-script-source (1.9.1) 63 | columnize (0.9.0) 64 | daemons (1.2.2) 65 | debug_inspector (0.0.2) 66 | diff-lcs (1.2.5) 67 | eco (1.0.0) 68 | coffee-script 69 | eco-source 70 | execjs 71 | eco-source (1.1.0.rc.1) 72 | ejs (1.1.1) 73 | erubis (2.7.0) 74 | eventmachine (1.0.7) 75 | execjs (2.5.0) 76 | factory_girl (4.5.0) 77 | activesupport (>= 3.0.0) 78 | factory_girl_rails (4.5.0) 79 | factory_girl (~> 4.5.0) 80 | railties (>= 3.0.0) 81 | faker (1.4.3) 82 | i18n (~> 0.5) 83 | faraday (0.9.1) 84 | multipart-post (>= 1.2, < 3) 85 | figaro (1.1.0) 86 | thor (~> 0.14) 87 | friendly_id (5.1.0) 88 | activerecord (>= 4.0.0) 89 | globalid (0.3.3) 90 | activesupport (>= 4.1.0) 91 | hashie (3.4.1) 92 | hike (1.2.3) 93 | i18n (0.7.0) 94 | jbuilder (2.2.12) 95 | activesupport (>= 3.0.0, < 5) 96 | multi_json (~> 1.2) 97 | jquery-rails (4.0.3) 98 | rails-dom-testing (~> 1.0) 99 | railties (>= 4.2.0) 100 | thor (>= 0.14, < 2.0) 101 | json (1.8.2) 102 | jwt (1.4.1) 103 | libv8 (3.16.14.11) 104 | loofah (2.0.1) 105 | nokogiri (>= 1.5.9) 106 | mail (2.6.3) 107 | mime-types (>= 1.16, < 3) 108 | method_source (0.8.2) 109 | mime-types (2.4.3) 110 | mini_portile (0.6.2) 111 | minitest (5.5.1) 112 | multi_json (1.11.0) 113 | multi_xml (0.5.5) 114 | multipart-post (2.0.0) 115 | newrelic_rpm (3.11.1.284) 116 | nokogiri (1.6.6.2) 117 | mini_portile (~> 0.6.0) 118 | oauth2 (1.0.0) 119 | faraday (>= 0.8, < 0.10) 120 | jwt (~> 1.0) 121 | multi_json (~> 1.3) 122 | multi_xml (~> 0.5) 123 | rack (~> 1.2) 124 | omniauth (1.2.2) 125 | hashie (>= 1.2, < 4) 126 | rack (~> 1.0) 127 | omniauth-google-oauth2 (0.2.6) 128 | omniauth (> 1.0) 129 | omniauth-oauth2 (~> 1.1) 130 | omniauth-oauth2 (1.2.0) 131 | faraday (>= 0.8, < 0.10) 132 | multi_json (~> 1.3) 133 | oauth2 (~> 1.0) 134 | omniauth (~> 1.2) 135 | pg (0.18.1) 136 | pry (0.10.1) 137 | coderay (~> 1.1.0) 138 | method_source (~> 0.8.1) 139 | slop (~> 3.4) 140 | pry-byebug (3.2.0) 141 | byebug (~> 5.0) 142 | pry (~> 0.10) 143 | pry-rails (0.3.4) 144 | pry (>= 0.9.10) 145 | rack (1.6.0) 146 | rack-test (0.6.3) 147 | rack (>= 1.0) 148 | rails (4.2.0) 149 | actionmailer (= 4.2.0) 150 | actionpack (= 4.2.0) 151 | actionview (= 4.2.0) 152 | activejob (= 4.2.0) 153 | activemodel (= 4.2.0) 154 | activerecord (= 4.2.0) 155 | activesupport (= 4.2.0) 156 | bundler (>= 1.3.0, < 2.0) 157 | railties (= 4.2.0) 158 | sprockets-rails 159 | rails-deprecated_sanitizer (1.0.3) 160 | activesupport (>= 4.2.0.alpha) 161 | rails-dom-testing (1.0.6) 162 | activesupport (>= 4.2.0.beta, < 5.0) 163 | nokogiri (~> 1.6.0) 164 | rails-deprecated_sanitizer (>= 1.0.1) 165 | rails-html-sanitizer (1.0.2) 166 | loofah (~> 2.0) 167 | rails_12factor (0.0.3) 168 | rails_serve_static_assets 169 | rails_stdout_logging 170 | rails_serve_static_assets (0.0.4) 171 | rails_stdout_logging (0.0.3) 172 | railties (4.2.0) 173 | actionpack (= 4.2.0) 174 | activesupport (= 4.2.0) 175 | rake (>= 0.8.7) 176 | thor (>= 0.18.1, < 2.0) 177 | rake (10.4.2) 178 | rdoc (4.2.0) 179 | ref (2.0.0) 180 | rspec-core (3.2.3) 181 | rspec-support (~> 3.2.0) 182 | rspec-expectations (3.2.1) 183 | diff-lcs (>= 1.2.0, < 2.0) 184 | rspec-support (~> 3.2.0) 185 | rspec-mocks (3.2.1) 186 | diff-lcs (>= 1.2.0, < 2.0) 187 | rspec-support (~> 3.2.0) 188 | rspec-rails (3.2.1) 189 | actionpack (>= 3.0, < 4.3) 190 | activesupport (>= 3.0, < 4.3) 191 | railties (>= 3.0, < 4.3) 192 | rspec-core (~> 3.2.0) 193 | rspec-expectations (~> 3.2.0) 194 | rspec-mocks (~> 3.2.0) 195 | rspec-support (~> 3.2.0) 196 | rspec-support (3.2.2) 197 | sass (3.2.19) 198 | sass-rails (4.0.5) 199 | railties (>= 4.0.0, < 5.0) 200 | sass (~> 3.2.2) 201 | sprockets (~> 2.8, < 3.0) 202 | sprockets-rails (~> 2.0) 203 | sdoc (0.4.1) 204 | json (~> 1.7, >= 1.7.7) 205 | rdoc (~> 4.0) 206 | shoulda-matchers (2.8.0) 207 | activesupport (>= 3.0.0) 208 | slop (3.6.0) 209 | spring (1.3.4) 210 | sprockets (2.12.3) 211 | hike (~> 1.2) 212 | multi_json (~> 1.0) 213 | rack (~> 1.0) 214 | tilt (~> 1.1, != 1.3.0) 215 | sprockets-rails (2.2.4) 216 | actionpack (>= 3.0) 217 | activesupport (>= 3.0) 218 | sprockets (>= 2.8, < 4.0) 219 | therubyracer (0.12.2) 220 | libv8 (~> 3.16.14.0) 221 | ref 222 | thin (1.6.3) 223 | daemons (~> 1.0, >= 1.0.9) 224 | eventmachine (~> 1.0) 225 | rack (~> 1.0) 226 | thor (0.19.1) 227 | thread_safe (0.3.5) 228 | tilt (1.4.1) 229 | tzinfo (1.2.2) 230 | thread_safe (~> 0.1) 231 | uglifier (2.7.1) 232 | execjs (>= 0.3.0) 233 | json (>= 1.8.0) 234 | web-console (2.1.2) 235 | activemodel (>= 4.0) 236 | binding_of_caller (>= 0.7.2) 237 | railties (>= 4.0) 238 | sprockets-rails (>= 2.0, < 4.0) 239 | 240 | PLATFORMS 241 | ruby 242 | 243 | DEPENDENCIES 244 | backbone-on-rails 245 | bcrypt 246 | better_errors 247 | binding_of_caller 248 | coffee-rails (~> 4.0.0) 249 | factory_girl_rails 250 | faker 251 | figaro 252 | friendly_id 253 | jbuilder (~> 2.0) 254 | jquery-rails 255 | newrelic_rpm 256 | omniauth-google-oauth2 257 | pg 258 | pry-byebug 259 | pry-rails 260 | rails (= 4.2) 261 | rails_12factor 262 | rspec-rails 263 | sass-rails (~> 4.0.3) 264 | sdoc (~> 0.4.0) 265 | shoulda-matchers 266 | spring 267 | therubyracer 268 | thin 269 | uglifier (>= 1.3.0) 270 | web-console 271 | 272 | BUNDLED WITH 273 | 1.10.6 274 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Workflowy Clone 2 | A clone of workflowy.com using a backbone front-end served by a RESTful rails api. -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/app/assets/images/.keep -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require jquery-ui 16 | //= require underscore 17 | //= require backbone 18 | //= require keymaster 19 | //= require workflowy 20 | //= require_tree ../templates 21 | //= require_tree ./models 22 | //= require_tree ./collections 23 | //= require_tree ./views 24 | //= require_tree ./routers 25 | //= require_tree . 26 | -------------------------------------------------------------------------------- /app/assets/javascripts/collections/items.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Collections.Items = Backbone.Collection.extend({ 5 | url: function() { 6 | if (this.parent) { 7 | return '/items/' + this.parent.get('uuid'); 8 | } else { 9 | return '/items'; 10 | } 11 | }, 12 | 13 | model: Workflowy.Models.Item, 14 | comparator: 'rank', 15 | _rankIncrement: 100, 16 | 17 | initialize: function(models, options) { 18 | if (options) { 19 | this.parent = options.parent; 20 | } 21 | }, 22 | 23 | insertAt: function(item, position) { 24 | var newRank = this._rankForPosition(position); 25 | item.collection = this; 26 | item.set('parent_id', this.parent ? this.parent.id : null); 27 | 28 | if (newRank !== parseInt(newRank)) { 29 | item.set('rank', position * this._rankIncrement); 30 | this._rerank(function() { 31 | item.save({}, { 32 | success: function(item, attributes) { 33 | item.set(attributes, {parse: true}); 34 | } 35 | }); 36 | }.bind(this)); 37 | this.add(item); 38 | } 39 | 40 | else { 41 | item.set('rank', newRank); 42 | this.add(item); 43 | item.save({}, { 44 | success: function(item, attributes) { 45 | item.set(attributes, {parse: true}); 46 | } 47 | }); 48 | } 49 | }, 50 | 51 | createAt: function(position) { 52 | var newItem = new Workflowy.Models.Item({ 53 | uuid: Workflowy.generateUUID() 54 | }); 55 | 56 | this.insertAt(newItem, position); 57 | 58 | return newItem; 59 | }, 60 | 61 | _rankForPosition: function(position) { 62 | if (position < -1 || position > this.length) { 63 | throw 'invalid rank specified'; 64 | } 65 | 66 | if (position === -1) { 67 | var lastRank = this.last() ? this.last().get('rank') : 0; 68 | return lastRank + this._rankIncrement; 69 | } 70 | 71 | var lowRank = this.at(position - 1) && this.at(position - 1).get('rank'), 72 | highRank = this.at(position) && this.at(position).get('rank'); 73 | 74 | return this._rankBetween(lowRank, highRank); 75 | }, 76 | 77 | _rankBetween: function(lowRank, highRank) { 78 | var newRank; 79 | 80 | if (lowRank === undefined) { 81 | newRank = parseInt(highRank / 2); 82 | } else if (highRank === undefined) { 83 | newRank = lowRank + this._rankIncrement; 84 | } else { 85 | newRank = parseInt((lowRank + highRank) / 2); 86 | } 87 | 88 | if (newRank === lowRank || newRank === highRank) { 89 | newRank += 0.5; 90 | } 91 | 92 | return newRank; 93 | }, 94 | 95 | _rerank: function(callback) { 96 | var rankMap = {}; 97 | this.forEach(function(item, index) { 98 | item.set('rank', (index + 1) * this._rankIncrement); 99 | rankMap[item.get('id')] = item.get('rank'); 100 | }.bind(this)); 101 | 102 | $.ajax({ 103 | url: this.url() + '/rerank', 104 | type: 'patch', 105 | contentType: 'application/json', 106 | data: JSON.stringify({ranks: rankMap}), 107 | success: callback 108 | }); 109 | } 110 | }); 111 | })(Workflowy); 112 | -------------------------------------------------------------------------------- /app/assets/javascripts/models/item.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Models.Item = Backbone.Model.extend({ 5 | url: function() { 6 | if (this.isNew()) { 7 | if (this.collection && this.collection.parent) { 8 | return this.collection.parent.url(); 9 | } else { 10 | return '/items'; 11 | } 12 | } 13 | return '/items/' + this.get('uuid'); 14 | }, 15 | 16 | initialize: function() { 17 | this.set({ 18 | title: this.get('title') || '', 19 | notes: this.get('notes') || '' 20 | }); 21 | 22 | this.listenTo(this, 'change', this.updateChangeTime); 23 | this.listenTo(this, 'sync', this.updateSyncTime); 24 | }, 25 | 26 | children: function() { 27 | if (!this._children) { 28 | this._children = new Workflowy.Collections.Items([], {parent: this}); 29 | } 30 | return this._children; 31 | }, 32 | 33 | parent: function() { 34 | return this.collection.parent; 35 | }, 36 | 37 | parse: function(response) { 38 | Workflowy.flatItems.add(this); 39 | 40 | if (response.children) { 41 | this.children().set( 42 | response.children, 43 | {parse: true} 44 | ); 45 | 46 | delete response.children; 47 | } 48 | 49 | return response.item; 50 | }, 51 | 52 | toJSON: function() { 53 | return _.omit(this.attributes, 'collapsed'); 54 | }, 55 | 56 | destroy: function() { 57 | this.children().forEach(function(item) { 58 | item.destroy(); 59 | }); 60 | 61 | Backbone.Model.prototype.destroy.apply(this, arguments); 62 | }, 63 | 64 | updateChangeTime: function() { 65 | this._changeTime = new Date(); 66 | }, 67 | 68 | updateSyncTime: function() { 69 | this._syncTime = new Date(); 70 | }, 71 | 72 | persisted: function() { 73 | if (this.isNew()) { 74 | return false; 75 | } else if (this._changeTime === undefined) { 76 | return true; 77 | } else if (this._syncTime === undefined) { 78 | return false; 79 | } 80 | return this._syncTime.getTime() >= this._changeTime.getTime(); 81 | }, 82 | 83 | shortenedNotes: function() { 84 | var notes = this.escape('notes') || ''; 85 | var lines = notes.split(/\r?\n/, 1); 86 | 87 | if (lines[0].length < notes.length) { 88 | return lines[0] + '...'; 89 | } else { 90 | return lines[0]; 91 | } 92 | }, 93 | 94 | aTag: function() { 95 | var uuid = this.get('uuid'); 96 | var title = this.escape('title'); 97 | 98 | return '' + title + ''; 99 | }, 100 | 101 | toggleCollapsed: function() { 102 | if (this.children().isEmpty()) return; 103 | this.set('collapsed', !this.get('collapsed')); 104 | 105 | $.ajax({ 106 | url: this.url() + '/collapse', 107 | type: 'patch', 108 | 109 | success: function(response) { 110 | window.response = response; 111 | this.set('collapsed', response.collapsed); 112 | }.bind(this) 113 | }); 114 | }, 115 | 116 | title: function(value) { 117 | if (value === undefined) return this.get('title'); 118 | 119 | if (value !== this.get('title')) { 120 | this.save({title: value}, { 121 | patch: true, 122 | success: function() { 123 | } 124 | }); 125 | } 126 | return this.get('title'); 127 | }, 128 | 129 | notes: function(value) { 130 | if (value === undefined) return this.get('title'); 131 | 132 | if (value !== this.get('notes')) { 133 | this.save({notes: value}, { 134 | patch: true, 135 | success: function() { 136 | } 137 | }); 138 | } 139 | return this.get('notes') || ''; 140 | }, 141 | 142 | index: function() { 143 | return this.collection.indexOf(this); 144 | }, 145 | 146 | leadSibling: function() { 147 | return this.collection.at(this.index() - 1); 148 | }, 149 | 150 | tailSibling: function() { 151 | return this.collection.at(this.index() + 1); 152 | }, 153 | 154 | indent: function() { 155 | if (!this.leadSibling()) return; 156 | 157 | var newCollection = this.leadSibling().children(); 158 | this.collection.remove(this); 159 | newCollection.insertAt(this, -1); 160 | }, 161 | 162 | outdent: function() { 163 | if ( 164 | !this.collection || 165 | !this.collection.parent || 166 | !this.collection.parent.collection 167 | ) { 168 | throw "Item cannot be outdented"; 169 | } 170 | 171 | var position = this.collection.parent.index() + 1; 172 | var newCollection = this.collection.parent.collection; 173 | this.collection.remove(this); 174 | newCollection.insertAt(this, position); 175 | 176 | }, 177 | 178 | nearestNeighbor: function(options) { 179 | var traverse = options.traverse, 180 | pick = options.pick; 181 | 182 | var stepsUp = -1; 183 | var ancestor = this._walkUpUntil(function(item) { 184 | ++stepsUp; 185 | return !!traverse.call(item); 186 | }); 187 | 188 | if (!ancestor) { 189 | return null; 190 | } 191 | var newAncestor = traverse.call(ancestor); 192 | return newAncestor._leaf(pick, stepsUp); 193 | }, 194 | 195 | above: function() { 196 | if (this.leadSibling()) { 197 | return this.leadSibling()._leaf(this.collection.last); 198 | } 199 | 200 | else { 201 | return this.collection.parent; 202 | } 203 | }, 204 | 205 | below: function() { 206 | if (this.children().length > 0) { 207 | return this.children().first(); 208 | } 209 | 210 | else { 211 | var ancestor = this._walkUpUntil(function(item) { 212 | return !!item.tailSibling(); 213 | }); 214 | return ancestor ? ancestor.tailSibling() : null; 215 | } 216 | }, 217 | 218 | _leaf: function(step, maxSteps) { 219 | var item = this, 220 | list = this.children(); 221 | 222 | while (list && (maxSteps === undefined || maxSteps)) { 223 | if (maxSteps) --maxSteps; 224 | 225 | if (step.call(list)) { 226 | item = step.call(list); 227 | list = item.children(); 228 | } else { 229 | list = null; 230 | } 231 | } 232 | 233 | return item; 234 | }, 235 | 236 | _walkUpUntil: function(condition) { 237 | if (condition(this)) { 238 | return this; 239 | } 240 | else if (!this.collection.parent) { 241 | return null; 242 | } 243 | else { 244 | return this.collection.parent._walkUpUntil(condition); 245 | } 246 | } 247 | }); 248 | })(Workflowy); 249 | -------------------------------------------------------------------------------- /app/assets/javascripts/routers/router.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Routers.Router = Backbone.Router.extend({ 5 | routes: { 6 | '': 'index', 7 | ':uuid': 'show' 8 | }, 9 | 10 | initialize: function(options) { 11 | this.$rootEl = options.$rootEl; 12 | }, 13 | 14 | index: function() { 15 | var view = new Workflowy.Views.Index({collection: Workflowy.items}); 16 | this._swapView(view); 17 | }, 18 | 19 | show: function(uuid) { 20 | var item = Workflowy.flatItems.findWhere({uuid: uuid}); 21 | var view = new Workflowy.Views.Show({model: item}); 22 | this._swapView(view); 23 | }, 24 | 25 | _swapView: function(newView) { 26 | if (this._currentView) this._currentView.remove(); 27 | 28 | this._currentView = newView; 29 | this.$rootEl.html(newView.render().$el); 30 | } 31 | }); 32 | })(Workflowy); 33 | -------------------------------------------------------------------------------- /app/assets/javascripts/views/items/index_view.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Views.Index = Backbone.View.extend({ 5 | 6 | template: JST['items/index'], 7 | 8 | initialize: function() { 9 | this.$el.addClass('page'); 10 | 11 | this.sublist = new Workflowy.Views.List({collection: this.collection}); 12 | this.listenTo(this.collection, 'add remove sort', this.render); 13 | }, 14 | 15 | events: { 16 | 'click .new': 'addInitialItem' 17 | }, 18 | 19 | render: function() { 20 | this.$el.html(this.template({items: this.collection})); 21 | this.$el.children('.padded').append(this.sublist.render().$el); 22 | 23 | if (this.collection.length === 0) { 24 | this.$el.children('.padded').html('+'); 25 | } 26 | return this; 27 | }, 28 | 29 | remove: function() { 30 | this.sublist.remove(); 31 | return Backbone.View.prototype.remove.apply(this, arguments); 32 | }, 33 | 34 | addInitialItem: function() { 35 | var newItem = this.collection.createAt(0); 36 | 37 | if (newItem.view) { 38 | newItem.view.focus(); 39 | } 40 | else { 41 | this.listenToOnce(newItem, 'viewCreated', function(){ 42 | newItem.view.focus(); 43 | }); 44 | } 45 | } 46 | }); 47 | })(Workflowy); 48 | -------------------------------------------------------------------------------- /app/assets/javascripts/views/items/item_view.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Views.Item = Workflowy.Views.Item || Backbone.View.extend(); 5 | _.extend(Workflowy.Views.Item.prototype, { 6 | 7 | tagName: 'li', 8 | template: JST['items/item'], 9 | 10 | initialize: function() { 11 | window.viewsCount = window.viewsCount || 0; 12 | window.viewsCount += 1; 13 | this.$el.addClass('item'); 14 | this.$el.data('uuid', this.model.get('uuid')); 15 | 16 | this.sublist = new Workflowy.Views.List({ 17 | collection: this.model.children() 18 | }); 19 | 20 | this.bindShortcuts(); 21 | this.listenTo(this.model, 'change', this.render); 22 | this.listenTo(this.model, 'destroy', this.remove); 23 | this.listenTo(this.model.children(), 'add remove', this.render); 24 | this.setDragoppable(); 25 | }, 26 | 27 | render: function() { 28 | if (this.isBeingEdited()) { 29 | var selection = this.getSelection(); 30 | } 31 | 32 | this.$el.find('li.item').detach(); 33 | this.$el.html(this.template({item: this.model})); 34 | 35 | if (!this.model.get('collapsed')) { 36 | var listSection = this.$el.children('section.indented'); 37 | listSection.html(this.sublist.render().$el); 38 | } 39 | 40 | if (selection) { 41 | this.restoreSelection(selection); 42 | } 43 | 44 | return this; 45 | }, 46 | 47 | remove: function() { 48 | this.sublist.remove(); 49 | this.model.view = null; 50 | return Backbone.View.prototype.remove.apply(this, arguments); 51 | }, 52 | 53 | isBeingEdited: function(input) { 54 | if (input) { 55 | input = '.' + input; 56 | } else { 57 | input = ''; 58 | } 59 | return this.$el.children('p' + input + ':focus').length > 0; 60 | }, 61 | 62 | retainFocus: function(action) { 63 | if (this.isBeingEdited()) { 64 | var selection = this.getSelection(); 65 | } 66 | 67 | var result = action.call(this); 68 | 69 | if (selection) { 70 | this.restoreSelection(selection); 71 | } 72 | return result; 73 | }, 74 | 75 | focus: function(field, offset) { 76 | field = field || 'title'; 77 | offset = offset || this.model.get('title').length; 78 | 79 | var selection = { 80 | edited: '.' + field, 81 | startOffset: offset, 82 | endOffset: offset 83 | }; 84 | 85 | this.restoreSelection(selection); 86 | }, 87 | 88 | getSelection: function() { 89 | var selection = {}; 90 | var range = window.getSelection().getRangeAt(0); 91 | 92 | selection.edited = this.isBeingEdited('title') ? '.title' : '.notes'; 93 | 94 | selection.startOffset = range.startOffset; 95 | selection.endOffset = range.endOffset; 96 | // if the character was a return and the browser inserted an element 97 | // just set the selection to be after the linebreak instead 98 | var notes = this.$el.children('.notes')[0]; 99 | if (notes.firstChild !== notes.lastChild) { 100 | selection.startOffset = notes.firstChild.textContent.length + 1; 101 | selection.endOffset = selection.startOffset; 102 | } 103 | 104 | return selection; 105 | }, 106 | 107 | restoreSelection: function(selection) { 108 | var el = this.$el.children(selection.edited)[0]; 109 | el.focus(); 110 | 111 | var textNode = el.childNodes[0]; 112 | if (!textNode) { 113 | return; 114 | } 115 | 116 | var range = document.createRange(); 117 | range.setStart(textNode, selection.startOffset); 118 | range.setEnd(textNode, selection.endOffset); 119 | 120 | window.getSelection().removeAllRanges(); 121 | window.getSelection().addRange(range); 122 | }, 123 | 124 | isOutdentable: function() { 125 | // Don't outdent if 126 | // - we are the item being shown 127 | // - we are in the top level list of the whole document 128 | // - we are nested directly underneath the item being shown 129 | return ( 130 | this.model.view && 131 | this.model.collection.parent && 132 | this.model.collection.parent.view 133 | ); 134 | }, 135 | 136 | createNewItem: function() { 137 | if (this.model.title() !== '' && this.getSelection().startOffset === 0) { 138 | var position = this.model.index(); 139 | } else { 140 | var position = this.model.index() + 1; 141 | } 142 | 143 | var newItem = this.model.collection.createAt(position); 144 | 145 | if (newItem.view) { 146 | newItem.view.focus(); 147 | } 148 | else { 149 | this.listenToOnce(newItem, 'viewCreated', function(){ 150 | newItem.view.focus(); 151 | }); 152 | } 153 | } 154 | }); 155 | })(Workflowy); 156 | -------------------------------------------------------------------------------- /app/assets/javascripts/views/items/item_view_events.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Views.Item = Workflowy.Views.Item || Backbone.View.extend(); 5 | _.extend(Workflowy.Views.Item.prototype, { 6 | 7 | events: { 8 | 'click .collapser': 'toggleCollapsed', // select this more precisely? 9 | 'input .title': 'changeTitle', 10 | 'focus .notes': 'expandNotes', 11 | 'blur .notes': 'collapseNotes', 12 | 'input .notes': 'changeNotes', 13 | 'focus p': 'activateShortcuts', 14 | 'blur p': 'disableShortcuts', 15 | 'moved': 'itemMoved' 16 | }, 17 | 18 | toggleCollapsed: function() { 19 | event.stopPropagation(); 20 | this.model.toggleCollapsed(); 21 | }, 22 | 23 | changeTitle: function(event) { 24 | event.stopPropagation(); 25 | this.model.title($(event.currentTarget).text()); 26 | }, 27 | 28 | changeNotes: function(event) { 29 | event.stopPropagation(); 30 | this.model.notes(event.currentTarget.innerText); 31 | }, 32 | 33 | expandNotes: function(event) { 34 | event.stopPropagation(); 35 | event.currentTarget.innerHTML = this.model.escape('notes'); 36 | }, 37 | 38 | collapseNotes: function(event) { 39 | event.stopPropagation(); 40 | event.currentTarget.innerHTML = this.model.shortenedNotes(); 41 | }, 42 | 43 | activateShortcuts: function(event) { 44 | event.stopPropagation(); 45 | key.setScope(this.model.cid); 46 | }, 47 | 48 | disableShortcuts: function(event) { 49 | event.stopPropagation(); 50 | key.setScope('main'); 51 | }, 52 | 53 | bindShortcuts: function() { 54 | var key = function(keys, f) { 55 | window.key(keys, this.model.cid, f.bind(this)); 56 | }.bind(this); 57 | 58 | key('return', this.shortcutReturn); 59 | key('backspace', this.shortcutBackspace); 60 | 61 | key('shift + ctrl + right, tab', this.shortcutIndent); 62 | key('shift + ctrl + left, shift + tab', this.shortcutOutdent); 63 | key('shift + ctrl + up', this.shortcutMoveUp); 64 | key('shift + ctrl + down', this.shortcutMoveDown); 65 | 66 | key('shift + return', this.shortcutSwapField); 67 | key('up', this.shortcutFocusUp); 68 | key('down', this.shortcutFocusDown); 69 | }, 70 | 71 | shortcutReturn: function(event) { 72 | if (!this.isBeingEdited('title')) return; 73 | event.preventDefault(); 74 | 75 | if (this.model.title() === '' && this.isOutdentable()) { 76 | this.retainFocus(function() { 77 | this.model.outdent(); 78 | }); 79 | } else { 80 | this.createNewItem(); 81 | } 82 | }, 83 | 84 | shortcutBackspace: function(event) { 85 | if (this.isDeletable()) { 86 | event.preventDefault(); 87 | 88 | if (this.model.leadSibling()) { 89 | var nextFocus = this.model.leadSibling(); 90 | this.model.leadSibling().save({ 91 | title: this.model.leadSibling().title() + this.model.title() 92 | }); 93 | } 94 | 95 | else { 96 | var nextFocus = this.model.above(); 97 | } 98 | 99 | this.model.destroy(); 100 | if (nextFocus) nextFocus.view.focus(); 101 | } 102 | 103 | else if (this.isBeingEdited('notes') && this.model.notes() === '') { 104 | event.preventDefault(); 105 | this.focus('title'); 106 | } 107 | }, 108 | 109 | isDeletable: function() { 110 | return ( 111 | this.isBeingEdited('title') && 112 | this.model.children().length === 0 && 113 | this.getSelection().startOffset === 0 && 114 | (!this.model.title() || this.model.leadSibling()) 115 | ); 116 | }, 117 | 118 | shortcutSwapField: function(event) { 119 | event.preventDefault(); 120 | 121 | this.isBeingEdited('title') ? this.focus('notes') : this.focus('title'); 122 | }, 123 | 124 | shortcutIndent: function(event) { 125 | event.preventDefault(); 126 | if (!this.isBeingEdited('title')) return; 127 | 128 | this.retainFocus(function() { 129 | this.model.indent(); 130 | }); 131 | }, 132 | 133 | shortcutOutdent: function(event) { 134 | event.preventDefault(); 135 | if (!this.isBeingEdited('title')) return; 136 | 137 | if (this.isOutdentable()) { 138 | this.retainFocus(function() { 139 | this.model.outdent(); 140 | }); 141 | } 142 | }, 143 | 144 | shortcutMoveUp: function(event) { 145 | event.preventDefault(); 146 | var list, newPosition; 147 | 148 | var neighbor = this.model.nearestNeighbor({ 149 | traverse: this.model.leadSibling, 150 | pick: this.model.collection.last 151 | }); 152 | 153 | if (this.model.index() > 0) { 154 | list = this.model.collection; 155 | newPosition = this.model.index() - 1; 156 | } 157 | 158 | else if (neighbor) { 159 | list = neighbor.collection; 160 | newPosition = neighbor.index() + 1; 161 | } 162 | 163 | else { 164 | return; 165 | } 166 | 167 | this.retainFocus(function() { 168 | this.model.collection.remove(this.model); 169 | list.insertAt(this.model, newPosition); 170 | }); 171 | }, 172 | 173 | shortcutMoveDown: function(event) { 174 | event.preventDefault(); 175 | var list, newPosition; 176 | 177 | var neighbor = this.model.nearestNeighbor({ 178 | traverse: this.model.tailSibling, 179 | pick: this.model.collection.first 180 | }); 181 | 182 | if (this.model.index() < this.model.collection.length - 1) { 183 | list = this.model.collection; 184 | newPosition = this.model.index() + 1; 185 | } 186 | 187 | else if (neighbor) { 188 | newPosition = neighbor.index(); 189 | } 190 | 191 | else { 192 | return; 193 | } 194 | 195 | this.retainFocus(function() { 196 | this.model.collection.remove(this.model); 197 | list.insertAt(this.model, newPosition); 198 | }); 199 | }, 200 | 201 | shortcutFocusUp: function(event) { 202 | if (this.isBeingEdited('notes')) return; 203 | event.preventDefault(); 204 | 205 | var itemAbove = this.model.above(); 206 | if (itemAbove) itemAbove.view.focus(); 207 | }, 208 | 209 | shortcutFocusDown: function(event) { 210 | if (this.isBeingEdited('notes')) return; 211 | event.preventDefault(); 212 | 213 | var itemBelow = this.model.below(); 214 | if (itemBelow) itemBelow.view.focus(); 215 | }, 216 | 217 | setDragoppable: function() { 218 | var view = this; 219 | 220 | this.$el.droppable({ 221 | hoverClass: 'draggable-hover', 222 | greedy: true, 223 | 224 | drop: function(event, ui) { 225 | ui.draggable.trigger('moved', {parent: view.model}); 226 | } 227 | }); 228 | 229 | this.$el.draggable({ 230 | handle: '.bullet', 231 | helper: function() { 232 | return view.$el.children('.bullet'); 233 | }, 234 | tolerance: 'pointer', 235 | 236 | cursorAt: {top: 10, left: 10} 237 | }); 238 | }, 239 | 240 | itemMoved: function(event, data) { 241 | event.stopPropagation(); 242 | var parent = data.parent; 243 | 244 | this.model.collection.remove(this.model); 245 | parent.children().insertAt(this.model, 0); 246 | this.render(); 247 | this.focus(); 248 | } 249 | }); 250 | })(Workflowy); 251 | -------------------------------------------------------------------------------- /app/assets/javascripts/views/items/list_view.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Views.List = Backbone.View.extend({ 5 | tagName: 'ul', 6 | 7 | initialize: function() { 8 | window.viewCount = window.viewCount || 0; 9 | window.viewCount += 1; 10 | this.$el.addClass('list'); 11 | 12 | this.listenTo(this.collection, 'remove sort', this.render); 13 | this.listenTo(this.collection, 'add', this.addNewItemView); 14 | }, 15 | 16 | render: function() { 17 | this.$el.children().detach(); 18 | 19 | this.collection.forEach(function(item) { 20 | if (!item.view) item.view = new Workflowy.Views.Item({model: item}); 21 | item.view.delegateEvents(); 22 | 23 | this.$el.append(item.view.render().$el); 24 | }.bind(this)); 25 | 26 | return this; 27 | }, 28 | 29 | remove: function() { 30 | this.collection.forEach(function(item) { 31 | item.view.remove(); 32 | }); 33 | return Backbone.View.prototype.remove.apply(this, arguments); 34 | }, 35 | 36 | addNewItemView: function(item) { 37 | if (!item.view) { 38 | item.view = new Workflowy.Views.Item({model: item}); 39 | item.trigger('viewCreated'); 40 | } 41 | this.render(); 42 | } 43 | }); 44 | })(Workflowy); 45 | -------------------------------------------------------------------------------- /app/assets/javascripts/views/items/show_view.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Views.Show = Backbone.View.extend({ 5 | template: JST['items/show'], 6 | 7 | initialize: function() { 8 | this.$el.addClass('page'); 9 | 10 | this.sublist = new Workflowy.Views.List({ 11 | collection: this.model.children() 12 | }); 13 | 14 | this.listenTo(this.model, 'change', this.render); 15 | }, 16 | 17 | events: { 18 | 'input .title': 'changeTitle', 19 | 'input .notes': 'changeNotes' 20 | }, 21 | 22 | render: function() { 23 | var html = this.template({ 24 | item: this.model, 25 | breadcrumbs: this.breadcrumbs() 26 | }); 27 | 28 | this.$el.html(html); 29 | this.$el.find('article').html(this.sublist.render().$el); 30 | 31 | return this; 32 | }, 33 | 34 | remove: function() { 35 | this.sublist.remove(); 36 | return Backbone.View.prototype.remove.apply(this, arguments); 37 | }, 38 | 39 | breadcrumbs: function() { 40 | var breadcrumbs = []; 41 | var item = this.model; 42 | 43 | while (item) { 44 | //item = Workflowy.lookup.id[item.get('parent_id')]; 45 | item = item.collection.parent; 46 | if (item) { 47 | breadcrumbs.unshift(item.aTag()); 48 | } else { 49 | breadcrumbs.unshift('Home'); 50 | } 51 | } 52 | 53 | return breadcrumbs.join(' > '); 54 | }, 55 | 56 | changeTitle: function(event) { 57 | event.stopPropagation(); 58 | this.model.title($(event.currentTarget).text()); 59 | }, 60 | 61 | changeNotes: function(event) { 62 | event.stopPropagation(); 63 | this.model.notes(event.currentTarget.innerText); 64 | } 65 | }); 66 | })(Workflowy); -------------------------------------------------------------------------------- /app/assets/javascripts/views/saved_view.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Views.Saved = Backbone.View.extend({ 5 | tagName: 'li', 6 | 7 | initialize: function() { 8 | this.$el.addClass('saved'); 9 | this.unsavedItems = new Workflowy.Collections.Items(); 10 | 11 | this.listenTo(this.collection, 12 | 'change:title change:notes change:parent change:rank', 13 | this.modelChanged 14 | ); 15 | this.listenTo(this.collection, 'sync', this.modelSynced); 16 | }, 17 | 18 | render: function() { 19 | if (this.unsavedItems.length > 0) { 20 | this.$el.html('Save now'); 21 | } else { 22 | this.$el.html('saved'); 23 | } 24 | return this; 25 | }, 26 | 27 | modelChanged: function(model) { 28 | this.unsavedItems.add(model); 29 | if (this.unsavedItems.length === 1) { 30 | this.render(); 31 | } 32 | }, 33 | 34 | modelSynced: function(model) { 35 | if (model instanceof Backbone.Collection) { 36 | return; 37 | } 38 | if (model.persisted()) { 39 | this.unsavedItems.remove(model); 40 | if (this.unsavedItems.length === 0) { 41 | this.render(); 42 | } 43 | } 44 | } 45 | }); 46 | })(Workflowy); 47 | -------------------------------------------------------------------------------- /app/assets/javascripts/views/undo_view.js: -------------------------------------------------------------------------------- 1 | ;(function(Workflowy) { 2 | "use strict"; 3 | 4 | Workflowy.Views.Undo = Backbone.View.extend({ 5 | tag: 'li', 6 | 7 | initialize: function() { 8 | this.$el.addClass('undos'); 9 | 10 | this._undos = []; 11 | key('ctrl + z', 'all', this.undo.bind(this)); 12 | this._redos = []; 13 | key('shift + ctrl + z', 'all', this.redo.bind(this)); 14 | 15 | this.listenTo(this.collection, 'change:title', this.recordTitleChange); 16 | this.listenTo(this.collection, 'change:notes', this.recordNotesChange); 17 | this.listenTo(this.collection, 'add', this.recordAdd); 18 | this.listenTo(this.collection, 'remove', this.recordRemove); 19 | this.listenTo(this.collection, 'destroy', this.recordDestroy); 20 | }, 21 | 22 | render: function() { 23 | this.$el.html( 24 | '
  • undo
  • ' + 25 | '
  • redo
  • ' 26 | ); 27 | return this; 28 | }, 29 | 30 | events: { 31 | 'click .undo': 'undo', 32 | 'click .redo': 'redo' 33 | }, 34 | 35 | undo: function() { 36 | if (this._undos.length === 0) return; 37 | event.preventDefault(); 38 | 39 | var action = this._undos.pop(); 40 | action.undo(); 41 | this._redos.push(action); 42 | 43 | if (this._redos.length === 1) { 44 | this.$el.children('.redo').addClass('usable'); 45 | } 46 | if (this._undos.length === 0) { 47 | this.$el.children('.undo').removeClass('usable'); 48 | } 49 | }, 50 | 51 | redo: function() { 52 | if (this._undos.length === 0) return; 53 | event.preventDefault(); 54 | 55 | var action = this._redos.pop(); 56 | action.redo(); 57 | this.pushUndo(action); 58 | 59 | if (this._redos.length === 0) { 60 | this.$el.children('.redo').removeClass('usable'); 61 | } 62 | }, 63 | 64 | pushUndo: function(action) { 65 | this._undos.push(action); 66 | if (this._undos.length === 1) { 67 | this.$el.children('.undo').addClass('usable'); 68 | } 69 | }, 70 | 71 | recordTitleChange: function(item, title, options) { 72 | if (options.undoIgnore) return; 73 | 74 | var action = { 75 | _previous: item.previous('title'), 76 | _current: item.get('title'), 77 | _time: new Date(), 78 | 79 | undo: function() { 80 | item.set('title', this._previous, {undoIgnore: true}); 81 | item.save(); 82 | }, 83 | 84 | redo: function() { 85 | item.set('title', this._current, {undoIgnore: true}); 86 | item.save(); 87 | } 88 | }; 89 | 90 | this.pushUndo(action); 91 | }, 92 | 93 | recordNotesChange: function(item, notes, options) { 94 | }, 95 | 96 | recordAdd: function(item, list, options) { 97 | }, 98 | 99 | recordRemove: function(item, list, options) { 100 | }, 101 | 102 | recordDestroy: function(item, list, options) { 103 | } 104 | }); 105 | })(Workflowy); 106 | -------------------------------------------------------------------------------- /app/assets/javascripts/workflowy.js: -------------------------------------------------------------------------------- 1 | ;(function() { 2 | "use strict"; 3 | 4 | window.Workflowy = { 5 | Models: {}, 6 | Collections: {}, 7 | Views: {}, 8 | Routers: {}, 9 | 10 | initialize: function() { 11 | Workflowy.flatItems = new Workflowy.Collections.Items( 12 | [], 13 | {comparator: 'uuid'} 14 | ); 15 | 16 | var itemsJSON = $('#bootstrapped_items_json').html(); 17 | var items = JSON.parse(itemsJSON).items; 18 | Workflowy.items = new Workflowy.Collections.Items(items, {parse: true}); 19 | 20 | var savedView = new Workflowy.Views.Saved( 21 | {collection: Workflowy.flatItems} 22 | ); 23 | $('.saved').replaceWith(savedView.render().$el); 24 | 25 | key('backspace', 'main', function(event) { event.preventDefault(); }); 26 | key.setScope('main'); 27 | 28 | new Workflowy.Routers.Router({$rootEl: $('#content')}); 29 | Backbone.history.start(); 30 | }, 31 | 32 | generateUUID: function() { 33 | var template = "xxxxxxxx-xxxx-yxxx-yxxx-xxxxxxxxxxxx"; 34 | 35 | var uuid = template.replace(/x/g, function() { 36 | return Math.floor(Math.random() * 16).toString(16); 37 | }); 38 | 39 | // set the weird bit 40 | uuid = uuid.replace('y', function() { 41 | return ['8', '9', 'a', 'b'][Math.floor(Math.random() * 4)]; 42 | }); 43 | 44 | return uuid; 45 | } 46 | }; 47 | })(); 48 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or vendor/assets/stylesheets of plugins, if any, can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require reset.css 14 | *= require_tree . 15 | *= require_self 16 | */ -------------------------------------------------------------------------------- /app/assets/stylesheets/items.css.scss: -------------------------------------------------------------------------------- 1 | .star { 2 | height: 30px; 3 | } 4 | 5 | .breadcrumbs { 6 | padding-bottom: 3px; 7 | border-bottom: 1px solid #ddd; 8 | margin-bottom: 15px; 9 | } 10 | 11 | .padded { 12 | padding: 0px 20px; 13 | } 14 | 15 | .indented { 16 | padding-left: 11px; 17 | border-left: 1px solid #ddd; 18 | margin-left: 24px; 19 | margin-top: 2px; 20 | } 21 | 22 | .item { 23 | @extend .clear-group; 24 | padding-bottom: 5px; 25 | } 26 | 27 | .new { 28 | display: block; 29 | font-size: 20px; 30 | color: #555; 31 | cursor: text; 32 | } 33 | 34 | //.item.draggable-hover { 35 | // padding-bottom: 0px; 36 | // 37 | // > .indented { 38 | // border-bottom: 5px solid #ccc; 39 | // } 40 | //} 41 | 42 | .collapser { 43 | display: block; 44 | float: left; 45 | text-align: center; 46 | padding: 0px; 47 | width: 15px; 48 | height: 20px; 49 | } 50 | 51 | .bullet { 52 | display: block; 53 | float: left; 54 | 55 | width: 8px; 56 | height: 8px; 57 | 58 | padding: 5px; 59 | border-radius: 9px; 60 | 61 | &:hover { 62 | background: #aaa; 63 | } 64 | 65 | > div { 66 | width: 0; 67 | height: 0; 68 | border: 4px solid #666; 69 | border-radius: 4px; 70 | } 71 | } 72 | 73 | header { 74 | > p.title { 75 | margin: 0; 76 | font-size: 28px; 77 | } 78 | 79 | > p.notes { 80 | white-space: pre-wrap; 81 | font-size: 16px; 82 | color: #666; 83 | } 84 | } 85 | 86 | p.title { 87 | margin-left: 34px; 88 | outline: 0; 89 | } 90 | p.notes { 91 | white-space: pre-wrap; 92 | margin-left: 34px; 93 | font-size: 13px; 94 | color: #666; 95 | outline: 0; 96 | } -------------------------------------------------------------------------------- /app/assets/stylesheets/nav.css.scss: -------------------------------------------------------------------------------- 1 | section.header-nav { 2 | $nav-height: 29px; 3 | 4 | width: 100%; 5 | min-width: 800px; 6 | height: $nav-height; 7 | background: #333; 8 | background: -webkit-gradient( linear, left bottom, left top, color-stop(1, #444), color-stop(0, #333) ); 9 | color: white; 10 | border-bottom: 1px solid black; 11 | 12 | a { 13 | color: white; 14 | cursor: pointer; 15 | :visited { 16 | color: white; 17 | } 18 | } 19 | 20 | a, .nav-text { 21 | display: block; 22 | } 23 | 24 | .nav-text { 25 | line-height: $nav-height; 26 | vertical-align: middle; 27 | } 28 | 29 | nav { 30 | h1 { 31 | float: left; 32 | font-weight: lighter; 33 | font-size: 17px; 34 | line-height: 1; 35 | padding: 5px 20px 7px 20px; 36 | } 37 | 38 | li { 39 | float: left; 40 | background-color: #555; 41 | } 42 | 43 | > ul { 44 | border-right: 1px solid black; 45 | > li, div > li { 46 | border-left: 1px solid black; 47 | } 48 | } ul { 49 | float: right; 50 | margin-right: 15px; 51 | 52 | li.user-dropdown { 53 | position: relative; 54 | width: 28px; 55 | height: $nav-height; 56 | 57 | > div { 58 | width: 0px; 59 | height: 0px; 60 | background: transparent; 61 | margin: 12px auto; 62 | 63 | border-top: 5px solid white; 64 | border-left: 5px solid transparent; 65 | border-right: 5px solid transparent; 66 | border-bottom: 0; 67 | } 68 | 69 | &:hover ul { 70 | display: block; 71 | } ul { 72 | display: none; 73 | :hover { 74 | display: block; 75 | } 76 | 77 | top: $nav-height; 78 | right: 0; 79 | margin-right: -1px; 80 | position: absolute; 81 | 82 | background-color: #666; 83 | padding: 8px; 84 | border: 1px solid black; 85 | border-top: none; 86 | border-radius: 0 0 5px 5px; 87 | 88 | li { 89 | padding: 5px 0; 90 | background-color: #666; 91 | width: 100%; 92 | font-size: 13px; 93 | } 94 | 95 | .user-email { 96 | font-size: 11px; 97 | color: #aaa; 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | 105 | .undos { 106 | float: left; 107 | } 108 | 109 | .saved { 110 | text-align: center; 111 | width: 85px; 112 | } 113 | -------------------------------------------------------------------------------- /app/assets/stylesheets/reset.css: -------------------------------------------------------------------------------- 1 | body, h1, h2, nav, ul, li, header, main, section, article, aside, p, a { 2 | margin: 0; 3 | padding: 0; 4 | border: 0; 5 | font: inherit; 6 | } 7 | 8 | body { 9 | font-family: sans-serif; 10 | } 11 | 12 | a { 13 | color: black; 14 | text-decoration: none; 15 | } 16 | 17 | a:visited { 18 | color: black; 19 | } 20 | 21 | ul { 22 | list-style: none; 23 | } 24 | 25 | button, 26 | input[type="submit"], 27 | input[type="text"], 28 | input[type="password"] { 29 | display: inline; 30 | -webkit-appearance: none; 31 | box-sizing: content-box; 32 | margin: 0; 33 | padding: 0; 34 | border: 0; 35 | outline: 0; 36 | width: auto; 37 | font: inherit; 38 | vertical-align: baseline; 39 | background: transparent; 40 | color: inherit; 41 | } 42 | 43 | input[type="submit"], 44 | button { 45 | cursor: pointer; 46 | } -------------------------------------------------------------------------------- /app/assets/stylesheets/root.css.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the root controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | 5 | body { 6 | background: #efefef; 7 | } 8 | 9 | .page { 10 | background: white; 11 | width: 800px; 12 | min-height: 1000px; 13 | padding: 10px; 14 | border: 1px solid #ccc; 15 | border-radius: 8px; 16 | margin: 30px auto; 17 | box-shadow: 0 0 16px #ccc; 18 | } -------------------------------------------------------------------------------- /app/assets/stylesheets/sessions.css.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the sessions controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | 5 | nav { 6 | label, button { 7 | background: transparent; 8 | margin: 5px 5px 6px 5px; 9 | } 10 | 11 | } 12 | 13 | input[type="text"], input[type="password"] { 14 | background: white; 15 | border: 1px solid black; 16 | color: black; 17 | } 18 | 19 | .login { 20 | display: block; 21 | margin: auto; 22 | margin-top: 100px; 23 | background: #555; 24 | width: 270px; 25 | padding: 30px; 26 | border-radius: 7px; 27 | 28 | label, input, a, button { 29 | display: block; 30 | } 31 | 32 | label, a, button { 33 | color: white; 34 | } 35 | 36 | input[type="text"], input[type="password"] { 37 | display: block; 38 | padding: 5px; 39 | font-size: 20px; 40 | width: 258px; 41 | margin: 10px 0; 42 | } 43 | 44 | button, a { 45 | text-align: center; 46 | border: 2px solid black; 47 | background: #334; 48 | padding: 5px; 49 | font-size: 20px; 50 | width: 258px; 51 | margin-top: 25px; 52 | border-radius: 2px; 53 | 54 | &:hover { 55 | background-color: #445; 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /app/assets/stylesheets/shares.css.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the Shares controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/users.css.scss: -------------------------------------------------------------------------------- 1 | // Place all the styles related to the Users controller here. 2 | // They will automatically be included in application.css. 3 | // You can use Sass (SCSS) here: http://sass-lang.com/ 4 | -------------------------------------------------------------------------------- /app/assets/stylesheets/utility.css.scss: -------------------------------------------------------------------------------- 1 | .clear-group { 2 | &:after { 3 | content: ''; 4 | display: block; 5 | clear: both; 6 | } 7 | } -------------------------------------------------------------------------------- /app/assets/templates/items/index.jst.ejs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 |
    -------------------------------------------------------------------------------- /app/assets/templates/items/item.jst.ejs: -------------------------------------------------------------------------------- 1 | 2 | <% if (item.children().length > 0) { %> 3 | <%= item.get('collapsed') ? '+' : '-' %> 4 | <% } %> 5 | 6 | 7 |
    8 |
    9 | 10 |

    <%= item.escape('title') %>

    11 |

    <%= item.shortenedNotes() %>

    12 | 13 |
    14 |
    -------------------------------------------------------------------------------- /app/assets/templates/items/show.jst.ejs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 6 |
    7 |

    <%= item.escape('title') %> 8 |

    <%= item.escape('notes') %> 9 |

    10 |
    11 |
    12 |
    -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | 4 | helper_method :current_user, :signed_in? 5 | 6 | private 7 | 8 | def current_user 9 | @current_user ||= User.find_by(session_token: session[:token]) 10 | end 11 | 12 | def signed_in? 13 | !!current_user 14 | end 15 | 16 | def sign_in!(user) 17 | @current_user = user 18 | session[:token] = user.session_token 19 | end 20 | 21 | def sign_out! 22 | current_user.try(:reset_session_token!) 23 | session[:token] = nil 24 | end 25 | 26 | def require_signed_in 27 | redirect_to new_session_url unless signed_in? 28 | end 29 | 30 | include ItemsHelper 31 | end 32 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/app/controllers/concerns/.keep -------------------------------------------------------------------------------- /app/controllers/items_controller.rb: -------------------------------------------------------------------------------- 1 | class ItemsController < ApplicationController 2 | before_action :require_signed_in 3 | before_action :require_owner, except: [:index, :create, :rerank] 4 | 5 | def index 6 | @nested_items = current_user.nested_items 7 | @new_item = current_user.items.new 8 | end 9 | 10 | def show 11 | @nested_items = current_user.nested_items 12 | @new_item = @item.children.new(rank: max_rank(nested_children(@item)) + 100) 13 | end 14 | 15 | def create 16 | if params[:id] 17 | @item = Item.friendly.find(params[:id]) 18 | return redirect_to items_url unless @item.can_edit?(current_user) 19 | end 20 | 21 | if item_params[:parent_id] 22 | @item = Item.find(item_params[:parent_id]) 23 | return redirect_to items_url unless @item.can_edit?(current_user) 24 | end 25 | 26 | @nested_items = current_user.nested_items 27 | 28 | @new_item = (@item ? @item.children : current_user.items).new(item_params) 29 | @new_item.rank ||= max_rank(nested_children(@item)) + 100 30 | 31 | if @new_item.save 32 | render json: @new_item 33 | else 34 | render status: 400, json: @new_item.errors 35 | end 36 | end 37 | 38 | def collapse 39 | view = @item.views.where(user_id: current_user.id).first 40 | view ||= @item.views.new(user_id: current_user.id) 41 | view.toggle_collapsed! 42 | 43 | render json: {collapsed: view.collapsed} 44 | end 45 | 46 | def update 47 | if item_params[:parent_id] 48 | parent_item = Item.find(item_params[:parent_id]) 49 | return redirect_to items_url unless @item.can_edit?(current_user) 50 | end 51 | 52 | if @item.update(item_params) 53 | render json: @item 54 | else 55 | render status: 400, json: @item.errors 56 | end 57 | end 58 | 59 | def destroy 60 | @item.destroy 61 | render json: @item 62 | end 63 | 64 | def rerank 65 | if Item.rerank(params[:ranks]) 66 | render json: 'ok' 67 | else 68 | render status: 400 69 | end 70 | end 71 | 72 | private 73 | 74 | def item_params 75 | params.require(:item).permit(:title, :notes, :rank, :uuid, :parent_id) 76 | end 77 | 78 | def require_owner 79 | @item = Item.friendly.find(params[:id]) 80 | redirect_to root_url unless @item.user_id == current_user.id 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /app/controllers/root_controller.rb: -------------------------------------------------------------------------------- 1 | class RootController < ApplicationController 2 | 3 | def index 4 | if signed_in? 5 | @nested_items = current_user.nested_items 6 | @nested_items.each do |key, item_hash| 7 | item_hash.delete(:parent) 8 | end 9 | @nested_items = @nested_items[nil][:children] 10 | render :index 11 | else 12 | @user = User.new 13 | render :home 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | 3 | def new 4 | @user = User.new 5 | end 6 | 7 | def create 8 | user = User.find_by_credentials(user_params[:email], user_params[:password]) 9 | 10 | if user 11 | sign_in!(user) 12 | redirect_to root_url 13 | else 14 | @user = User.new(user_params) 15 | flash.now[:alert] = 'Invalid username or password' 16 | render :new 17 | end 18 | end 19 | 20 | def destroy 21 | sign_out! 22 | redirect_to root_url 23 | end 24 | 25 | def google_login 26 | user = User.find_or_create_by_auth_hash(request.env['omniauth.auth']) 27 | sign_in!(user) 28 | 29 | redirect_to root_url 30 | end 31 | 32 | def demo 33 | demo_user = User.create!( 34 | email: SecureRandom.uuid + '@example.com', 35 | password: 'green1' 36 | ) 37 | 38 | sign_in!(demo_user) 39 | redirect_to root_url 40 | end 41 | 42 | private 43 | 44 | def user_params 45 | params.require(:user).permit(:email, :password) 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /app/controllers/shares_controller.rb: -------------------------------------------------------------------------------- 1 | class SharesController < ApplicationController 2 | before_action :require_signed_in 3 | before_action :require_owner, except: [:show] 4 | 5 | def index 6 | @shares = @item.shares.includes(:user) 7 | @share = @item.shares.new 8 | end 9 | 10 | def create 11 | @shares = @item.shares.includes(:user) 12 | @share = @item.shares.new(shares_params) 13 | 14 | if @share.save 15 | redirect_to item_shares_url(@item) 16 | else 17 | flash.now[:alert] = @share.errors.full_messages 18 | render :index 19 | end 20 | end 21 | 22 | def editable 23 | @share.toggle_editable! 24 | 25 | redirect_to item_shares_url(@item) 26 | end 27 | 28 | def destroy 29 | @share.destroy 30 | redirect_to item_shares_url(@share.item_id) 31 | end 32 | 33 | def show 34 | @item = Item.friendly.find(params[:id]) 35 | @new_item = @item.children.new 36 | @nested_items = @item.user.nested_items 37 | end 38 | 39 | private 40 | 41 | def shares_params 42 | params.require(:share).permit(:user_id, :can_edit) 43 | end 44 | 45 | def require_owner 46 | if params[:item_id] 47 | @item = Item.friendly.find(params[:item_id]) 48 | else 49 | @share = Share.find(params[:id]) 50 | @item = @share.item 51 | end 52 | redirect_to root_url unless @item.user_id == current_user.id 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | 3 | def new 4 | @user = User.new 5 | end 6 | 7 | def create 8 | @user = User.new(user_params) 9 | 10 | if @user.save 11 | sign_in!(@user) 12 | redirect_to root_url 13 | else 14 | flash.now[:alert] = @user.errors.full_messages 15 | render :new 16 | end 17 | end 18 | 19 | private 20 | 21 | def user_params 22 | params.require(:user).permit(:email, :password) 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/items_helper.rb: -------------------------------------------------------------------------------- 1 | module ItemsHelper 2 | 3 | def breadcrumbs(item) 4 | ancestors = [] 5 | parent = @nested_items[item.id][:parent][:item] 6 | 7 | until parent == nil 8 | ancestors.unshift(parent) 9 | parent = @nested_items[parent.id][:parent][:item] 10 | end 11 | 12 | ancestors.map do |item| 13 | "#{h(item.title)} >" 14 | end .join('') 15 | end 16 | 17 | def find_view(item) 18 | item.views.find { |view| view.user_id == current_user.id } || item.views.new 19 | end 20 | 21 | # mixed methods 22 | 23 | def nested_children(item) 24 | if @nested_items 25 | @nested_items[item.try(:id)][:children].map { |hash| hash[:item] } 26 | else 27 | [] 28 | end 29 | end 30 | 31 | def max_rank(parent) 32 | children = parent.respond_to?(:children) ? parent.children : parent.to_a 33 | children.max_by(&:rank).try(:rank) || 0 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /app/helpers/root_helper.rb: -------------------------------------------------------------------------------- 1 | module RootHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/sessions_helper.rb: -------------------------------------------------------------------------------- 1 | module SessionsHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/shares_helper.rb: -------------------------------------------------------------------------------- 1 | module SharesHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/helpers/users_helper.rb: -------------------------------------------------------------------------------- 1 | module UsersHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/app/mailers/.keep -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/app/models/.keep -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/app/models/concerns/.keep -------------------------------------------------------------------------------- /app/models/concerns/seed_demo_items.rb: -------------------------------------------------------------------------------- 1 | module SeedDemoItems 2 | extend ActiveSupport::Concern 3 | 4 | included do 5 | after_create :seed_demo_items 6 | end 7 | 8 | def seed_demo_items 9 | items.create!(rank: 1000, 10 | title: 'Click on an item to edit it') 11 | 12 | item = items.create!(rank: 2000, 13 | title: 'Press enter when focusing an item to create a new item') 14 | item.children.create!(rank: 1000, 15 | title: 'Press tab to indent an item') 16 | item.children.create!(rank: 2000, 17 | title: 'shift + tab will unindent an item') 18 | 19 | items.create!(rank: 3000, 20 | title: 'Return when on an empty line also unindents') 21 | 22 | item = items.create!(rank: 4000, 23 | title: 'To navigate through the document use the arrow keys') 24 | item.children.create!(rank: 1000, 25 | title: 'pressing up and down will focus the item above or below the currently selected one') 26 | 27 | item = items.create!(rank: 5000, 28 | title: 'shift + ctrl + up and shift + ctrl + down will move items around the page', 29 | notes: 'If you are on a mac and have launchpad shortcuts on those keys, this won\'t work') 30 | item.children.create!(rank: 1000, 31 | title: 'shift + ctrl + left and shift + ctrl + right can also be used to change indentation levels') 32 | 33 | items.create!(rank: 6000, 34 | title: 'shift + return will edit the notes for the currently focused item', 35 | notes: "Clicking here will also expand the notes and allow you to edit them\nThis part will be hidden until the notes are focused.\nPressing shift + return or click somewhere else will hide the full notes again.") 36 | 37 | item = items.create!(rank: 7000, 38 | title: 'When an item has nested notes') 39 | item.children.create!(rank: 1000, 40 | title: 'Everything underneath it will move with it if you move it around the page') 41 | item = item.children.create!(rank: 2000, 42 | title: 'When you create a nested item, a minus sign will appear next to its parent') 43 | item.children.create!(rank: 1000, 44 | title: 'Clicking on the minus will hide all of the nested notes') 45 | item = item.children.create!(rank: 2000, 46 | title: 'When there\'s a plus sign next to something, it can be expanded') 47 | item.views.create!(user: self, collapsed: true) 48 | item = item.children.create!(rank: 1000, 49 | title: 'You can nest notes as deep as you like this way') 50 | 51 | items.create!(rank: 8000, 52 | title: 'Click on an item to edit it') 53 | end 54 | end -------------------------------------------------------------------------------- /app/models/item.rb: -------------------------------------------------------------------------------- 1 | class Item < ActiveRecord::Base 2 | validates :user_id, :rank, :uuid, presence: true 3 | validates :rank, uniqueness: {scope: [:user_id, :parent_id]} 4 | validates :uuid, length: {is: 36}, uniqueness: true 5 | 6 | belongs_to :user 7 | has_many :views, dependent: :destroy 8 | has_many :shares 9 | 10 | belongs_to :parent, class_name: 'Item', inverse_of: :children 11 | 12 | has_many :children, class_name: 'Item', 13 | foreign_key: :parent_id, 14 | dependent: :destroy, 15 | inverse_of: :parent 16 | 17 | before_validation do 18 | self.user_id ||= parent.try(:user_id) 19 | self.uuid ||= SecureRandom::uuid 20 | self.title ||= '' 21 | self.notes ||= '' 22 | end 23 | 24 | extend FriendlyId 25 | friendly_id :uuid 26 | 27 | def self.rerank(ranks_hash) 28 | transaction do 29 | connection.execute 'SET CONSTRAINTS items_rank DEFERRED' 30 | ranks_hash.keys.each do |id| 31 | next if id == 'undefined' 32 | update(id, rank: ranks_hash[id]) 33 | end 34 | end 35 | end 36 | 37 | def shortened_notes 38 | '' 39 | end 40 | 41 | def can_view?(viewer) 42 | return true if user_id == viewer.try(:id) 43 | nested_items = user.nested_items 44 | 45 | current_item = nested_items[id][:item] 46 | until current_item == nil 47 | return true if current_item.is_shared_with?(viewer) 48 | current_item = nested_items[current_item.id][:parent][:item] 49 | end 50 | 51 | false 52 | end 53 | 54 | def can_edit?(editor) 55 | return true if user_id == editor.try(:id) 56 | nested_items = user.nested_items 57 | 58 | current_item = nested_items[id][:item] 59 | until current_item == nil 60 | return true if current_item.is_shared_with_edit?(editor) 61 | current_item = nested_items[current_item.id][:parent][:item] 62 | end 63 | 64 | false 65 | end 66 | 67 | protected 68 | 69 | def is_shared_with?(viewer) 70 | shares.any? do |share| 71 | share.user_id.nil? || share.user_id == viewer.try(:id) 72 | end 73 | end 74 | 75 | def is_shared_with_edit?(editor) 76 | shares.any? do |share| 77 | share.can_edit && (share.user_id.nil? || share.user_id == editor.try(:id)) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/models/share.rb: -------------------------------------------------------------------------------- 1 | class Share < ActiveRecord::Base 2 | validates :item_id, presence: true 3 | validates :user_id, uniqueness: {scope: :item_id} 4 | validates :can_edit, inclusion: {in: [false, true]} 5 | validate :no_self_sharing 6 | 7 | before_validation do 8 | self.can_edit = false if can_edit.nil? 9 | 10 | true 11 | end 12 | 13 | belongs_to :user 14 | belongs_to :item 15 | 16 | def toggle_editable! 17 | update!(can_edit: !can_edit) 18 | can_edit 19 | end 20 | 21 | private 22 | 23 | def no_self_sharing 24 | if !item || user_id == item.user_id 25 | errors.add(:user_id, 'can\'t share with yourself') 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | validates :email, :session_token, presence: true, uniqueness: true 3 | validates :password_digest, presence: true 4 | validates :password, length: {minimum: 6, allow_nil: true} 5 | 6 | has_many :items 7 | has_many :views 8 | has_many :shares 9 | has_many :shared_items, through: :shares, source: :item 10 | 11 | before_validation do 12 | self.session_token ||= SecureRandom.urlsafe_base64(32) 13 | end 14 | 15 | after_create do 16 | views.create! 17 | end 18 | 19 | include SeedDemoItems 20 | 21 | def self.find_by_credentials(email, password) 22 | user = find_by(email: email) 23 | 24 | user if user.try(:is_password?, password) 25 | end 26 | 27 | def self.find_or_create_by_auth_hash(auth_hash) 28 | user = self.find_by(uid: auth_hash[:uid], provider: auth_hash[:provider]) 29 | 30 | unless user 31 | user = create!( 32 | uid: auth_hash[:uid], 33 | provider: auth_hash[:provider], 34 | email: auth_hash[:info][:email], 35 | password_digest: SecureRandom::urlsafe_base64(16) 36 | ) 37 | end 38 | 39 | user 40 | end 41 | 42 | attr_reader :password 43 | 44 | def password=(secret) 45 | return unless secret 46 | self.password_digest = BCrypt::Password.create(secret) 47 | @password = secret 48 | end 49 | 50 | def is_password?(secret) 51 | BCrypt::Password.new(password_digest).is_password?(secret) 52 | end 53 | 54 | def reset_session_token! 55 | self.session_token = SecureRandom.urlsafe_base64(32) 56 | save! 57 | session_token 58 | end 59 | 60 | def nested_items 61 | @better_hash = Hash.new do |hash, key| 62 | hash[key] = {item: nil, parent: nil, children: []} 63 | end 64 | 65 | #TODO trim includes down? 66 | items 67 | .includes([:shares, :views]) # we are deprecating views here, at the least 68 | .joins('LEFT OUTER JOIN views ON items.id = views.item_id') 69 | .where('views.user_id IS NULL OR views.user_id = ?', id) 70 | .select('items.*, views.collapsed AS collapsed') 71 | .order(:rank) 72 | .each do |item| 73 | @better_hash[item.id][:item] = item 74 | @better_hash[item.id][:parent] = @better_hash[item.parent_id] 75 | @better_hash[item.parent_id][:children] << @better_hash[item.id] 76 | end 77 | 78 | @better_hash 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /app/models/view.rb: -------------------------------------------------------------------------------- 1 | class View < ActiveRecord::Base 2 | validates :user_id, presence: true 3 | validates :item_id, uniqueness: {scope: :user_id} 4 | validates :collapsed, :starred, inclusion: {in: [false, true]} 5 | 6 | before_validation do 7 | self.collapsed = false if collapsed.nil? 8 | self.starred = false if starred.nil? 9 | 10 | true 11 | end 12 | 13 | belongs_to :user 14 | belongs_to :item 15 | 16 | def toggle_collapsed! 17 | update!(collapsed: !collapsed) 18 | collapsed 19 | end 20 | end -------------------------------------------------------------------------------- /app/views/_view.html.erb: -------------------------------------------------------------------------------- 1 | <%= render view.item, collapsed: view.collapsed %> -------------------------------------------------------------------------------- /app/views/items/_form.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | 4 | 6 | 7 | 8 | 10 | 11 | 12 | 13 | 14 | 15 |
    -------------------------------------------------------------------------------- /app/views/items/_item.html.erb: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | 4 | 7 | 8 | 9 | 10 | 15 |
    16 | 17 | 18 | <%= item.title %> 19 | <%= item.shortened_notes %> 20 | <% unless find_view(item).collapsed %> 21 |
    22 | 25 |
    26 | <% end %> 27 |
  • -------------------------------------------------------------------------------- /app/views/items/index.html.erb: -------------------------------------------------------------------------------- 1 |

    Home

    2 | 3 | 6 |

    Add Item

    7 | <%= render 'form', item: @new_item %> -------------------------------------------------------------------------------- /app/views/items/show.html.erb: -------------------------------------------------------------------------------- 1 | 5 |
    6 | <%= render 'form', item: @item %> 7 |
    8 |
    9 | 12 |
    13 | <% if @item.can_edit?(current_user) %> 14 | 20 | <% end %> -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Workflowy Clone 5 | <%= stylesheet_link_tag 'application', media: 'all' %> 6 | <%= javascript_include_tag 'application' %> 7 | <%= csrf_meta_tags %> 8 | 9 | 10 | 11 |
    12 | 29 |
    30 | 31 |

    <%= flash.alert %>

    32 | 33 |
    34 | <%= yield %> 35 |
    36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /app/views/root/home.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <% unless signed_in? %> 3 | <%= render 'users/form' %> 4 | <% end %> 5 | <%= link_to 'sign In with Google', '/auth/google_oauth2' %> 6 | <%= link_to 'Sign In as Demo User', '#', class: 'demo' %> 7 |
    8 | 9 | -------------------------------------------------------------------------------- /app/views/root/index.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /app/views/sessions/_form.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | 4 | 5 | 6 | 8 | 9 | 10 | 12 | 13 | 14 |
    -------------------------------------------------------------------------------- /app/views/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 |

    Sign In

    2 | 3 | <%= render 'sessions/form' %> -------------------------------------------------------------------------------- /app/views/shares/_share.html.erb: -------------------------------------------------------------------------------- 1 |

    2 | <%= share.user.try(:email) || 'public' %> 3 |

    4 | 7 | 8 | 9 | 12 |
    13 |
    14 | 17 | 18 | 19 | 22 |
    23 |

    -------------------------------------------------------------------------------- /app/views/shares/index.html.erb: -------------------------------------------------------------------------------- 1 | <%= render @shares %> 2 | <%= form_for @share, url: item_shares_url(@item) do |f| %> 3 | <%= f.text_field :user_id %> 4 | <%= f.check_box :can_edit %> 5 | <%= f.submit %> 6 | <% end %> -------------------------------------------------------------------------------- /app/views/shares/show.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | <% if @item.can_edit?(current_user) %> 3 | <%= render 'items/form', item: @item %> 4 | <% else %> 5 |

    <%= @item.title %>

    6 |

    <%= @item.notes %>

    7 | <% end %> 8 |
    9 |
    10 | 13 |
    14 | <% if @item.can_edit?(current_user) %> 15 | 19 | <% end %> -------------------------------------------------------------------------------- /app/views/users/_form.html.erb: -------------------------------------------------------------------------------- 1 |
    2 | 4 | 5 | 6 | 8 | 9 | 10 | 12 | 13 | 14 |
    -------------------------------------------------------------------------------- /app/views/users/new.html.erb: -------------------------------------------------------------------------------- 1 |

    Sign Up

    2 | 3 | <%= render 'users/form' %> -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path("../spring", __FILE__) 4 | rescue LoadError 5 | end 6 | APP_PATH = File.expand_path('../../config/application', __FILE__) 7 | require_relative '../config/boot' 8 | require 'rails/commands' 9 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | begin 3 | load File.expand_path("../spring", __FILE__) 4 | rescue LoadError 5 | end 6 | require_relative '../config/boot' 7 | require 'rake' 8 | Rake.application.run 9 | -------------------------------------------------------------------------------- /bin/spring: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | # This file loads spring without using Bundler, in order to be fast 4 | # It gets overwritten when you run the `spring binstub` command 5 | 6 | unless defined?(Spring) 7 | require "rubygems" 8 | require "bundler" 9 | 10 | if match = Bundler.default_lockfile.read.match(/^GEM$.*?^ spring \((.*?)\)$.*?^$/m) 11 | ENV["GEM_PATH"] = ([Bundler.bundle_path.to_s] + Gem.path).join(File::PATH_SEPARATOR) 12 | ENV["GEM_HOME"] = "" 13 | Gem.paths = ENV 14 | 15 | gem "spring", match[1] 16 | require "spring/binstub" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | require "active_model/railtie" 5 | require "active_record/railtie" 6 | require "action_controller/railtie" 7 | require "action_mailer/railtie" 8 | require "action_view/railtie" 9 | require "sprockets/railtie" 10 | # require "rails/test_unit/railtie" 11 | 12 | # Require the gems listed in Gemfile, including any gems 13 | # you've limited to :test, :development, or :production. 14 | Bundler.require(*Rails.groups) 15 | 16 | module Workflowy 17 | class Application < Rails::Application 18 | 19 | config.generators do |g| 20 | g.test_framework :rspec, 21 | :fixtures => true, 22 | :view_specs => false, 23 | :helper_specs => false, 24 | :routing_specs => false, 25 | :controller_specs => true, 26 | :request_specs => true 27 | g.fixture_replacement :factory_girl, dir: 'spec/factories' 28 | 29 | g.javascript_engine :js 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | 4 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE']) 5 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 8.2 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On OS X with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On OS X with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | # For details on connection pooling, see rails configuration guide 21 | # http://guides.rubyonrails.org/configuring.html#database-pooling 22 | pool: 5 23 | template: template0 24 | 25 | development: 26 | <<: *default 27 | database: workflowy_development 28 | 29 | # The specified database role being used to connect to postgres. 30 | # To create additional roles in postgres see `$ createuser --help`. 31 | # When left blank, postgres will use the default role. This is 32 | # the same name as the operating system user that initialized the database. 33 | #username: workflowy 34 | 35 | # The password associated with the postgres role (username). 36 | #password: 37 | 38 | # Connect on a TCP socket. Omitted by default since the client uses a 39 | # domain socket that doesn't need configuration. Windows does not have 40 | # domain sockets, so uncomment these lines. 41 | #host: localhost 42 | 43 | # The TCP port the server listens on. Defaults to 5432. 44 | # If your server runs on a different port number, change accordingly. 45 | #port: 5432 46 | 47 | # Schema search path. The server defaults to $user,public 48 | #schema_search_path: myapp,sharedapp,public 49 | 50 | # Minimum log levels, in increasing order: 51 | # debug5, debug4, debug3, debug2, debug1, 52 | # log, notice, warning, error, fatal, and panic 53 | # Defaults to warning. 54 | #min_messages: notice 55 | 56 | # Warning: The database defined as "test" will be erased and 57 | # re-generated from your development database when you run "rake". 58 | # Do not set this db to the same as development or production. 59 | test: 60 | <<: *default 61 | database: workflowy_test 62 | 63 | # As with config/secrets.yml, you never want to store sensitive information, 64 | # like your database password, in your source code. If your source code is 65 | # ever seen by anyone, they now have access to your database. 66 | # 67 | # Instead, provide the password as a unix environment variable when you boot 68 | # the app. Read http://guides.rubyonrails.org/configuring.html#configuring-a-database 69 | # for a full rundown on how to provide these environment variables in a 70 | # production deployment. 71 | # 72 | # On Heroku and other platform providers, you may have a full connection URL 73 | # available as an environment variable. For example: 74 | # 75 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 76 | # 77 | # You can use this database configuration with: 78 | # 79 | # production: 80 | # url: <%= ENV['DATABASE_URL'] %> 81 | # 82 | production: 83 | <<: *default 84 | database: workflowy_production 85 | username: workflowy 86 | password: <%= ENV['WORKFLOWY_DATABASE_PASSWORD'] %> 87 | -------------------------------------------------------------------------------- /config/database.yml.travis: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | # For details on connection pooling, see rails configuration guide 5 | # http://guides.rubyonrails.org/configuring.html#database-pooling 6 | pool: 5 7 | template: template0 8 | 9 | test: 10 | <<: *default 11 | adapter: postgresql 12 | database: travis_ci_test 13 | username: postgres 14 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Adds additional error checking when serving assets at runtime. 31 | # Checks for improperly declared sprockets dependencies. 32 | # Raises helpful error messages. 33 | config.assets.raise_runtime_errors = true 34 | 35 | # Raises error for missing translations 36 | # config.action_view.raise_on_missing_translations = true 37 | end 38 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid. 20 | # config.action_dispatch.rack_cache = true 21 | 22 | # Disable Rails's static asset server (Apache or nginx will already do this). 23 | config.serve_static_assets = true 24 | 25 | # Compress JavaScripts and CSS. 26 | config.assets.js_compressor = :uglifier 27 | # config.assets.css_compressor = :sass 28 | 29 | # Do not fallback to assets pipeline if a precompiled asset is missed. 30 | config.assets.compile = true 31 | 32 | # Generate digests for assets URLs. 33 | config.assets.digest = true 34 | 35 | # Version of your assets, change this if you want to expire all your assets. 36 | config.assets.version = '1.0' 37 | 38 | # Specifies the header that your server uses for sending files. 39 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache 40 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx 41 | 42 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 43 | # config.force_ssl = true 44 | 45 | # Set to :debug to see everything in the log. 46 | config.log_level = :info 47 | 48 | # Prepend all log lines with the following tags. 49 | # config.log_tags = [ :subdomain, :uuid ] 50 | 51 | # Use a different logger for distributed setups. 52 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 53 | 54 | # Use a different cache store in production. 55 | # config.cache_store = :mem_cache_store 56 | 57 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 58 | # config.action_controller.asset_host = "http://assets.example.com" 59 | 60 | # Precompile additional assets. 61 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 62 | # config.assets.precompile += %w( search.js ) 63 | 64 | # Ignore bad email addresses and do not raise email delivery errors. 65 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 66 | # config.action_mailer.raise_delivery_errors = false 67 | 68 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 69 | # the I18n.default_locale when a translation cannot be found). 70 | config.i18n.fallbacks = true 71 | 72 | # Send deprecation notices to registered listeners. 73 | config.active_support.deprecation = :notify 74 | 75 | # Disable automatic flushing of the log to improve performance. 76 | # config.autoflush_log = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = ::Logger::Formatter.new 80 | 81 | # Do not dump schema after migrations. 82 | config.active_record.dump_schema_after_migration = false 83 | end 84 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static asset server for tests with Cache-Control for performance. 16 | config.serve_static_assets = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Print deprecation notices to the stderr. 35 | config.active_support.deprecation = :stderr 36 | 37 | # Raises error for missing translations 38 | # config.action_view.raise_on_missing_translations = true 39 | end 40 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /config/initializers/omniauth.rb: -------------------------------------------------------------------------------- 1 | Rails.application.config.middleware.use OmniAuth::Builder do 2 | provider :google_oauth2, ENV["GOOGLE_CLIENT_ID"], ENV["GOOGLE_CLIENT_SECRET"] 3 | end -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_workflowy_session' 4 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /config/newrelic.yml: -------------------------------------------------------------------------------- 1 | # 2 | # This file configures the New Relic Agent. New Relic monitors Ruby, Java, 3 | # .NET, PHP, Python and Node applications with deep visibility and low 4 | # overhead. For more information, visit www.newrelic.com. 5 | # 6 | # Generated July 03, 2014 7 | # 8 | # This configuration file is custom generated for app26739848@heroku.com 9 | 10 | 11 | # Here are the settings that are common to all environments 12 | common: &default_settings 13 | # ============================== LICENSE KEY =============================== 14 | 15 | # You must specify the license key associated with your New Relic 16 | # account. This key binds your Agent's data to your account in the 17 | # New Relic service. 18 | license_key: ENV['NEW_RELIC_LICENSE_KEY'] 19 | 20 | # Agent Enabled (Ruby/Rails Only) 21 | # Use this setting to force the agent to run or not run. 22 | # Default is 'auto' which means the agent will install and run only 23 | # if a valid dispatcher such as Mongrel is running. This prevents 24 | # it from running with Rake or the console. Set to false to 25 | # completely turn the agent off regardless of the other settings. 26 | # Valid values are true, false and auto. 27 | # 28 | # agent_enabled: auto 29 | 30 | # Application Name Set this to be the name of your application as 31 | # you'd like it show up in New Relic. The service will then auto-map 32 | # instances of your application into an "application" on your 33 | # dashboard page. If you want to map this instance into multiple 34 | # apps, like "AJAX Requests" and "All UI" then specify a semicolon 35 | # separated list of up to three distinct names, or a yaml list. 36 | # Defaults to the capitalized RAILS_ENV or RACK_ENV (i.e., 37 | # Production, Staging, etc) 38 | # 39 | # Example: 40 | # 41 | # app_name: 42 | # - Ajax Service 43 | # - All Services 44 | # 45 | app_name: My Application 46 | 47 | # When "true", the agent collects performance data about your 48 | # application and reports this data to the New Relic service at 49 | # newrelic.com. This global switch is normally overridden for each 50 | # environment below. (formerly called 'enabled') 51 | monitor_mode: true 52 | 53 | # Developer mode should be off in every environment but 54 | # development as it has very high overhead in memory. 55 | developer_mode: false 56 | 57 | # The newrelic agent generates its own log file to keep its logging 58 | # information separate from that of your application. Specify its 59 | # log level here. 60 | log_level: info 61 | 62 | # Optionally set the path to the log file This is expanded from the 63 | # root directory (may be relative or absolute, e.g. 'log/' or 64 | # '/var/log/') The agent will attempt to create this directory if it 65 | # does not exist. 66 | # log_file_path: 'log' 67 | 68 | # Optionally set the name of the log file, defaults to 'newrelic_agent.log' 69 | # log_file_name: 'newrelic_agent.log' 70 | 71 | # The newrelic agent communicates with the service via https by default. This 72 | # prevents eavesdropping on the performance metrics transmitted by the agent. 73 | # The encryption required by SSL introduces a nominal amount of CPU overhead, 74 | # which is performed asynchronously in a background thread. If you'd prefer 75 | # to send your metrics over http uncomment the following line. 76 | # ssl: false 77 | 78 | #============================== Browser Monitoring =============================== 79 | # New Relic Real User Monitoring gives you insight into the performance real users are 80 | # experiencing with your website. This is accomplished by measuring the time it takes for 81 | # your users' browsers to download and render your web pages by injecting a small amount 82 | # of JavaScript code into the header and footer of each page. 83 | browser_monitoring: 84 | # By default the agent automatically injects the monitoring JavaScript 85 | # into web pages. Set this attribute to false to turn off this behavior. 86 | auto_instrument: true 87 | 88 | # Proxy settings for connecting to the New Relic server. 89 | # 90 | # If a proxy is used, the host setting is required. Other settings 91 | # are optional. Default port is 8080. 92 | # 93 | # proxy_host: hostname 94 | # proxy_port: 8080 95 | # proxy_user: 96 | # proxy_pass: 97 | 98 | # The agent can optionally log all data it sends to New Relic servers to a 99 | # separate log file for human inspection and auditing purposes. To enable this 100 | # feature, change 'enabled' below to true. 101 | # See: https://newrelic.com/docs/ruby/audit-log 102 | audit_log: 103 | enabled: false 104 | 105 | # Tells transaction tracer and error collector (when enabled) 106 | # whether or not to capture HTTP params. When true, frameworks can 107 | # exclude HTTP parameters from being captured. 108 | # Rails: the RoR filter_parameter_logging excludes parameters 109 | # Java: create a config setting called "ignored_params" and set it to 110 | # a comma separated list of HTTP parameter names. 111 | # ex: ignored_params: credit_card, ssn, password 112 | capture_params: false 113 | 114 | # Transaction tracer captures deep information about slow 115 | # transactions and sends this to the New Relic service once a 116 | # minute. Included in the transaction is the exact call sequence of 117 | # the transactions including any SQL statements issued. 118 | transaction_tracer: 119 | 120 | # Transaction tracer is enabled by default. Set this to false to 121 | # turn it off. This feature is only available at the Professional 122 | # and above product levels. 123 | enabled: true 124 | 125 | # Threshold in seconds for when to collect a transaction 126 | # trace. When the response time of a controller action exceeds 127 | # this threshold, a transaction trace will be recorded and sent to 128 | # New Relic. Valid values are any float value, or (default) "apdex_f", 129 | # which will use the threshold for an dissatisfying Apdex 130 | # controller action - four times the Apdex T value. 131 | transaction_threshold: apdex_f 132 | 133 | # When transaction tracer is on, SQL statements can optionally be 134 | # recorded. The recorder has three modes, "off" which sends no 135 | # SQL, "raw" which sends the SQL statement in its original form, 136 | # and "obfuscated", which strips out numeric and string literals. 137 | record_sql: obfuscated 138 | 139 | # Threshold in seconds for when to collect stack trace for a SQL 140 | # call. In other words, when SQL statements exceed this threshold, 141 | # then capture and send to New Relic the current stack trace. This is 142 | # helpful for pinpointing where long SQL calls originate from. 143 | stack_trace_threshold: 0.500 144 | 145 | # Determines whether the agent will capture query plans for slow 146 | # SQL queries. Only supported in mysql and postgres. Should be 147 | # set to false when using other adapters. 148 | # explain_enabled: true 149 | 150 | # Threshold for query execution time below which query plans will 151 | # not be captured. Relevant only when `explain_enabled` is true. 152 | # explain_threshold: 0.5 153 | 154 | # Error collector captures information about uncaught exceptions and 155 | # sends them to New Relic for viewing 156 | error_collector: 157 | 158 | # Error collector is enabled by default. Set this to false to turn 159 | # it off. This feature is only available at the Professional and above 160 | # product levels. 161 | enabled: true 162 | 163 | # To stop specific errors from reporting to New Relic, set this property 164 | # to comma-separated values. Default is to ignore routing errors, 165 | # which are how 404's get triggered. 166 | ignore_errors: "ActionController::RoutingError,Sinatra::NotFound" 167 | 168 | # If you're interested in capturing memcache keys as though they 169 | # were SQL uncomment this flag. Note that this does increase 170 | # overhead slightly on every memcached call, and can have security 171 | # implications if your memcached keys are sensitive 172 | # capture_memcache_keys: true 173 | 174 | # Application Environments 175 | # ------------------------------------------ 176 | # Environment-specific settings are in this section. 177 | # For Rails applications, RAILS_ENV is used to determine the environment. 178 | # For Java applications, pass -Dnewrelic.environment to set 179 | # the environment. 180 | 181 | # NOTE if your application has other named environments, you should 182 | # provide newrelic configuration settings for these environments here. 183 | 184 | development: 185 | <<: *default_settings 186 | # Turn on communication to New Relic service in development mode 187 | monitor_mode: true 188 | app_name: My Application (Development) 189 | 190 | # Rails Only - when running in Developer Mode, the New Relic Agent will 191 | # present performance information on the last 100 transactions you have 192 | # executed since starting the mongrel. 193 | # NOTE: There is substantial overhead when running in developer mode. 194 | # Do not use for production or load testing. 195 | developer_mode: true 196 | 197 | test: 198 | <<: *default_settings 199 | # It almost never makes sense to turn on the agent when running 200 | # unit, functional or integration tests or the like. 201 | monitor_mode: false 202 | 203 | # Turn on the agent in production for 24x7 monitoring. NewRelic 204 | # testing shows an average performance impact of < 5 ms per 205 | # transaction, you can leave this on all the time without 206 | # incurring any user-visible performance degradation. 207 | production: 208 | <<: *default_settings 209 | monitor_mode: true 210 | 211 | # Many applications have a staging environment which behaves 212 | # identically to production. Support for that environment is provided 213 | # here. By default, the staging environment has the agent turned on. 214 | staging: 215 | <<: *default_settings 216 | monitor_mode: true 217 | app_name: My Application (Staging) 218 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root to: 'root#index' 3 | 4 | resource :session, only: [:new, :create, :destroy] do 5 | post :demo 6 | end 7 | get '/auth/google_oauth2/callback', to: 'sessions#google_login' 8 | resources :users, only: [:new, :create] 9 | 10 | resources :items, except: [:new, :edit], shallow: true do 11 | patch :rerank, on: :collection 12 | member do 13 | post action: :create 14 | patch :collapse 15 | patch :rerank 16 | end 17 | 18 | resources :shares, only: [:index, :create] 19 | end 20 | 21 | resources :shares, only: [:show, :destroy] do 22 | patch :editable, on: :member 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 6f2f8a0e035b0c04299f9460a74787c3b6106b031f6328a7be68b6c3b637438676f916ef7b3305ed35cbd02d46aa195a5e4d5d4914908e7bbf2c2f3a97c63ca4 15 | 16 | test: 17 | secret_key_base: c052b057e64152a4ffd7bcb9fca1d085da54190f28b2a8ff5e0db06cbd41f3813a09756b854d8fe71624227f2d845411217e551cf55582090c5918a4222ed753 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /db/migrate/20140618222800_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration 2 | def change 3 | create_table :users do |t| 4 | t.string :email, null: false 5 | t.string :password_digest, null: false 6 | t.string :session_token, length: 32, null: false 7 | 8 | t.timestamps 9 | end 10 | 11 | add_index :users, :email, unique: true 12 | add_index :users, :session_token, unique: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20140621025457_create_items.rb: -------------------------------------------------------------------------------- 1 | class CreateItems < ActiveRecord::Migration 2 | def change 3 | create_table :items do |t| 4 | t.references :user 5 | t.integer :parent_id 6 | t.integer :rank 7 | t.text :title 8 | t.text :notes 9 | 10 | t.timestamps 11 | end 12 | 13 | add_index :items, [:user_id, :parent_id, :rank], unique: true 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /db/migrate/20140624142227_create_views.rb: -------------------------------------------------------------------------------- 1 | class CreateViews < ActiveRecord::Migration 2 | def change 3 | create_table :views do |t| 4 | t.references :user, null: false 5 | t.references :item 6 | t.boolean :collapsed, null: false 7 | t.boolean :starred, null: false 8 | 9 | t.timestamps 10 | end 11 | 12 | add_index :views, [:user_id, :item_id], unique: true 13 | end 14 | end -------------------------------------------------------------------------------- /db/migrate/20140625132201_create_shares.rb: -------------------------------------------------------------------------------- 1 | class CreateShares < ActiveRecord::Migration 2 | def change 3 | create_table :shares do |t| 4 | t.references :user, index: true 5 | t.references :item, index: true, null: false 6 | t.boolean :can_edit, null: false 7 | 8 | t.timestamps 9 | end 10 | 11 | add_column :items, :url, :string, length: 43 12 | add_index :items, :url, unique: true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /db/migrate/20140626194425_change_items_url_to_uuid.rb: -------------------------------------------------------------------------------- 1 | class ChangeItemsUrlToUuid < ActiveRecord::Migration 2 | def change 3 | remove_index :items, :url 4 | remove_column :items, :url, :string, length: 43 5 | 6 | add_column :items, :uuid, :string, limit: 36, null: false 7 | add_index :items, :uuid, unique: true 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /db/migrate/20140627041832_add_uid_and_provider_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddUidAndProviderToUsers < ActiveRecord::Migration 2 | def change 3 | add_column :users, :uid, :string 4 | add_column :users, :provider, :string 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /db/migrate/20140629233057_allow_null_item_titles.rb: -------------------------------------------------------------------------------- 1 | class AllowNullItemTitles < ActiveRecord::Migration 2 | def up 3 | change_table :items do |t| 4 | t.change :title, :string, null: true 5 | t.change :user_id, :integer, null: false 6 | t.change :uuid, :string, null: false 7 | end 8 | end 9 | 10 | def down 11 | change_table :items do |t| 12 | t.change :title, :string, null: false 13 | t.change :user_id, :integer, null: true 14 | t.change :uuid, :string, null: true 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /db/migrate/20140630232756_item_rank_deferrable.rb: -------------------------------------------------------------------------------- 1 | class ItemRankDeferrable < ActiveRecord::Migration 2 | def up 3 | remove_index :items, [:user_id, :parent_id, :rank] 4 | 5 | execute <<-SQL 6 | ALTER TABLE items 7 | ADD CONSTRAINT items_rank UNIQUE (user_id, parent_id, rank) 8 | DEFERRABLE INITIALLY IMMEDIATE; 9 | SQL 10 | end 11 | 12 | def down 13 | execute <<-SQL 14 | ALTER TABLE items 15 | DROP CONSTRAINT IF EXISTS items_rank; 16 | SQL 17 | 18 | add_index :items, [:user_id, :parent_id, :rank], unique: true 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20140630232756) do 15 | 16 | # These are extensions that must be enabled in order to support this database 17 | enable_extension "plpgsql" 18 | 19 | create_table "items", force: true do |t| 20 | t.integer "user_id", null: false 21 | t.integer "parent_id" 22 | t.integer "rank" 23 | t.string "title" 24 | t.text "notes" 25 | t.datetime "created_at" 26 | t.datetime "updated_at" 27 | t.string "uuid", null: false 28 | end 29 | 30 | add_index "items", ["user_id", "parent_id", "rank"], name: "items_rank", unique: true, using: :btree 31 | add_index "items", ["uuid"], name: "index_items_on_uuid", unique: true, using: :btree 32 | 33 | create_table "shares", force: true do |t| 34 | t.integer "user_id" 35 | t.integer "item_id", null: false 36 | t.boolean "can_edit", null: false 37 | t.datetime "created_at" 38 | t.datetime "updated_at" 39 | end 40 | 41 | add_index "shares", ["item_id"], name: "index_shares_on_item_id", using: :btree 42 | add_index "shares", ["user_id"], name: "index_shares_on_user_id", using: :btree 43 | 44 | create_table "users", force: true do |t| 45 | t.string "email", null: false 46 | t.string "password_digest", null: false 47 | t.string "session_token", null: false 48 | t.datetime "created_at" 49 | t.datetime "updated_at" 50 | t.string "uid" 51 | t.string "provider" 52 | end 53 | 54 | add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree 55 | add_index "users", ["session_token"], name: "index_users_on_session_token", unique: true, using: :btree 56 | 57 | create_table "views", force: true do |t| 58 | t.integer "user_id", null: false 59 | t.integer "item_id" 60 | t.boolean "collapsed", null: false 61 | t.boolean "starred", null: false 62 | t.datetime "created_at" 63 | t.datetime "updated_at" 64 | end 65 | 66 | add_index "views", ["user_id", "item_id"], name: "index_views_on_user_id_and_item_id", unique: true, using: :btree 67 | 68 | end 69 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/lib/assets/.keep -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/lib/tasks/.keep -------------------------------------------------------------------------------- /log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/log/.keep -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The page you were looking for doesn't exist.

    62 |

    You may have mistyped the address or the page may have moved.

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    The change you wanted was rejected.

    62 |

    Maybe you tried to change something you didn't have access to.

    63 |
    64 |

    If you are the application owner check the logs for more information.

    65 |
    66 | 67 | 68 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
    60 |
    61 |

    We're sorry, but something went wrong.

    62 |
    63 |

    If you are the application owner check the logs for more information.

    64 |
    65 | 66 | 67 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/public/favicon.ico -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /spec/controllers/items_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe ItemsController, :type => :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/root_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe RootController, :type => :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/sessions_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe SessionsController, :type => :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/shares_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe SharesController, :type => :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/controllers/users_controller_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe UsersController, :type => :controller do 4 | 5 | end 6 | -------------------------------------------------------------------------------- /spec/factories/items.rb: -------------------------------------------------------------------------------- 1 | # Read about factories at https://github.com/thoughtbot/factory_girl 2 | 3 | FactoryGirl.define do 4 | factory :item do 5 | user_id 1 6 | sequence(:rank) { |n| n } 7 | title "title" 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/shares.rb: -------------------------------------------------------------------------------- 1 | # Read about factories at https://github.com/thoughtbot/factory_girl 2 | 3 | FactoryGirl.define do 4 | factory :share do 5 | user_id 1 6 | item_id 1 7 | can_edit nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/factories/users.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | factory :user do 3 | email "example@example.com" 4 | password "green1" 5 | end 6 | end -------------------------------------------------------------------------------- /spec/factories/views.rb: -------------------------------------------------------------------------------- 1 | # Read about factories at https://github.com/thoughtbot/factory_girl 2 | 3 | FactoryGirl.define do 4 | factory :view do 5 | user_id 1 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/models/item_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Item, :type => :model do 4 | 5 | context 'validations' do 6 | it 'validates when given a user_id and rank' do 7 | expect(create(:item)).to be_valid 8 | end 9 | 10 | it { should validate_presence_of(:user_id) } 11 | 12 | it { should validate_presence_of(:rank) } 13 | 14 | it do 15 | create(:item) 16 | 17 | should validate_uniqueness_of(:rank).scoped_to([:user_id, :parent_id]) 18 | end 19 | 20 | it 'can have the same rank as another item with a different parent' do 21 | parent = create(:item) 22 | first_child = parent.children.create(user_id: 1, rank: 1) 23 | second_parent = create(:item) 24 | second_child = second_parent.children.create(user_id: 1, rank: 1) 25 | expect(second_child).to be_valid 26 | end 27 | 28 | it do 29 | create(:item) 30 | 31 | should validate_uniqueness_of(:uuid) 32 | end 33 | it { should ensure_length_of(:uuid).is_equal_to(36) } 34 | 35 | it 'should automatically set a blank uuid' do 36 | item = build(:item, uuid: nil) 37 | item.valid? 38 | expect(item.uuid.length).to eq(36) 39 | end 40 | 41 | it 'should automatically set a blank title' do 42 | item = build(:item, title: nil) 43 | 44 | item.valid? 45 | 46 | expect(item.title).to eq('') 47 | end 48 | 49 | it 'should automatically set blank notes' do 50 | item = build(:item, notes: nil) 51 | 52 | item.valid? 53 | 54 | expect(item.notes).to eq('') 55 | end 56 | end 57 | 58 | context 'associations' do 59 | it { should belong_to(:user) } 60 | it { should belong_to(:parent) } 61 | it { should have_many(:children) } 62 | it { should have_many(:views) } 63 | it { should have_many(:shares) } 64 | end 65 | 66 | context '#can_view?' do 67 | let(:items) { [create(:item, id: 1, user: owner), 68 | create(:item, id: 2, parent_id: 1), 69 | create(:item, id: 3, parent_id: 2)] } 70 | let(:owner) { create(:user, id: 1) } 71 | let(:user) { User.create(id: 2, email: 'a', password: '123456') } 72 | 73 | it 'should be viewable by the owner' do 74 | expect(items.all? { |item| item.can_view?(owner) }).to be true 75 | end 76 | 77 | it 'shouldn\'t be viewable by another user' do 78 | expect(items.none? { |item| item.can_view?(user) }).to be true 79 | end 80 | 81 | it 'should be viewable by a user once shared' do 82 | items.first.shares.create!(user_id: 2) 83 | expect(items.all? { |item| item.can_view?(user) }).to be true 84 | end 85 | 86 | it 'should be viewable by anyone once shared anonymously' do 87 | items.first.shares.create!(user_id: nil) 88 | expect(items.all? { |item| item.can_view?(nil) }).to be true 89 | end 90 | end 91 | 92 | context '#can_edit?' do 93 | let(:items) { [create(:item, id: 1, user: owner), 94 | create(:item, id: 2, parent_id: 1), 95 | create(:item, id: 3, parent_id: 2)] } 96 | let(:owner) { create(:user, id: 1) } 97 | let(:user) { User.create(id: 2, email: 'a', password: '123456') } 98 | 99 | it 'should be editable by the owner' do 100 | expect(items.all? { |item| item.can_edit?(owner) }).to be true 101 | end 102 | 103 | it 'shouldn\'t be editable by another user' do 104 | expect(items.none? { |item| item.can_edit?(user) }).to be true 105 | end 106 | 107 | it 'should be editable by a user once shared with edit set' do 108 | items.first.shares.create!(user_id: 2, can_edit: true) 109 | expect(items.all? { |item| item.can_edit?(user) }).to be true 110 | end 111 | 112 | it 'should\'t be editable by a user if shared without edit set' do 113 | items.first.shares.create!(user_id: 2, can_edit: false) 114 | expect(items.none? { |item| item.can_edit?(user) }).to be true 115 | end 116 | 117 | it 'should be editable by anyone once shared anonymously with edit set' do 118 | items.first.shares.create!(user_id: nil, can_edit: true) 119 | expect(items.all? { |item| item.can_edit?(nil) }).to be true 120 | end 121 | 122 | it 'should\'t be editable by anonymous without edit set' do 123 | items.first.shares.create!(user_id: 2, can_edit: false) 124 | expect(items.none? { |item| item.can_edit?(nil) }).to be true 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /spec/models/share_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe Share, :type => :model do 4 | before(:each) do 5 | create(:user, id: 1) 6 | create(:item, id: 1, user_id: 1) 7 | end 8 | 9 | context 'validations' do 10 | it 'should be valid with a user_id and an item_id' do 11 | expect(create(:item).shares.new(user_id: 3)).to be_valid 12 | end 13 | 14 | it { should validate_presence_of(:item_id) } 15 | 16 | it do 17 | create(:share, user_id: 0, item_id: 1) 18 | should validate_uniqueness_of(:user_id).scoped_to(:item_id) 19 | end 20 | 21 | it 'should be able to have multiple users per item' do 22 | create(:share, user_id: 2, item_id: 1) 23 | expect(build(:share, user_id: 3, item_id: 1)).to be_valid 24 | end 25 | 26 | it 'should automatically set a nil can_edit' do 27 | share = build(:share, can_edit: nil) 28 | share.valid? 29 | expect(share.can_edit).to be false 30 | end 31 | 32 | it 'should not let you share with yourself' do 33 | expect(build(:share, user_id: 1, item_id: 1)).not_to be_valid 34 | end 35 | end 36 | 37 | context 'associations' do 38 | it { should belong_to(:user) } 39 | it { should belong_to(:item) } 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/models/user_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe User, :type => :model do 4 | let(:user) { create(:user) } 5 | 6 | it 'should not generate a password digest from a nil password' do 7 | expect(build(:user, password: nil).password_digest).to be_nil 8 | end 9 | 10 | it '#reset_session_token! should reset the session token' do 11 | starting_token = user.session_token 12 | user.reset_session_token! 13 | expect(user.session_token).not_to eq(starting_token) 14 | end 15 | 16 | context '#is_password?' do 17 | it 'should return true if the password matches' do 18 | expect(user.is_password?('green1')).to be true 19 | end 20 | 21 | it 'should return false if the password does not match' do 22 | expect(user.is_password?('green2')).to be false 23 | end 24 | end 25 | 26 | context '::find_by_credentials' do 27 | before(:each) do 28 | user 29 | end 30 | 31 | it 'should return the user if email and password match' do 32 | expect(User.find_by_credentials('example@example.com', 'green1')).to eq(user) 33 | end 34 | 35 | it 'should return nil if there is no such user' do 36 | expect(User.find_by_credentials('not_a_user', '')).to be nil 37 | end 38 | 39 | it 'should return nil if the password does not match' do 40 | expect(User.find_by_credentials('example@example.com', '')).to be nil 41 | end 42 | end 43 | 44 | context 'validations' do 45 | it 'validates when given an email and password' do 46 | expect(create(:user)).to be_valid 47 | end 48 | 49 | it 'must have an email' do 50 | expect(build(:user, email: nil)).not_to be_valid 51 | end 52 | 53 | it 'must have a password that is six characters or more' do 54 | expect(build(:user, password: 'abc')).not_to be_valid 55 | end 56 | 57 | it 'can have a nil password only when password digest is provied' do 58 | expect(build(:user, password: nil)).not_to be_valid 59 | expect(build(:user, password: nil, password_digest: 'a')).to be_valid 60 | end 61 | 62 | it 'should generate a session token on validation' do 63 | user = build(:user) 64 | expect(user.session_token).to be_nil 65 | user.valid? 66 | expect(user.session_token).not_to be_nil 67 | end 68 | end 69 | 70 | context 'associations' do 71 | it { should have_many(:items) } 72 | it { should have_many(:views) } 73 | it { should have_many(:shares) } 74 | it { should have_many(:shared_items) } 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /spec/models/view_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | RSpec.describe View, :type => :model do 4 | context 'validations' do 5 | it 'should be valid when created with a user id' do 6 | expect(create(:view)).to be_valid 7 | end 8 | 9 | it 'should automatically set the collapsed and starred to false' do 10 | view = build(:view, collapsed: nil, starred: nil) 11 | view.valid? 12 | expect(view.collapsed).to be false 13 | expect(view.starred).to be false 14 | end 15 | 16 | it 'should be invalid when created without a user id' do 17 | expect(build(:view, user_id: nil)).not_to be_valid 18 | end 19 | 20 | it 'should be invalid to create two views for the same item and user' do 21 | expect(create(:view, user_id: 1, item_id: 1)).to be_valid 22 | expect(build(:view, user_id: 1, item_id: 1)).not_to be_valid 23 | end 24 | end 25 | 26 | context 'associations' do 27 | it { should belong_to(:user) } 28 | it { should belong_to(:item) } 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | ENV["RAILS_ENV"] ||= 'test' 3 | require 'spec_helper' 4 | require File.expand_path("../../config/environment", __FILE__) 5 | require 'rspec/rails' 6 | 7 | # Requires supporting ruby files with custom matchers and macros, etc, in 8 | # spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are 9 | # run as spec files by default. This means that files in spec/support that end 10 | # in _spec.rb will both be required and run as specs, causing the specs to be 11 | # run twice. It is recommended that you do not name files matching this glob to 12 | # end with _spec.rb. You can configure this pattern with with the --pattern 13 | # option on the command line or in ~/.rspec, .rspec or `.rspec-local`. 14 | Dir[Rails.root.join("spec/support/**/*.rb")].each { |f| require f } 15 | 16 | # Checks for pending migrations before tests are run. 17 | # If you are not using ActiveRecord, you can remove this line. 18 | ActiveRecord::Migration.maintain_test_schema! 19 | 20 | RSpec.configure do |config| 21 | # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures 22 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 23 | 24 | # If you're not using ActiveRecord, or you'd prefer not to run each of your 25 | # examples within a transaction, remove the following line or assign false 26 | # instead of true. 27 | config.use_transactional_fixtures = true 28 | 29 | # RSpec Rails can automatically mix in different behaviours to your tests 30 | # based on their file location, for example enabling you to call `get` and 31 | # `post` in specs under `spec/controllers`. 32 | # 33 | # You can disable this behaviour by removing the line below, and instead 34 | # explicitly tag your specs with their type, e.g.: 35 | # 36 | # RSpec.describe UsersController, :type => :controller do 37 | # # ... 38 | # end 39 | # 40 | # The different available types are documented in the features, such as in 41 | # https://relishapp.com/rspec/rspec-rails/docs 42 | config.infer_spec_type_from_file_location! 43 | end 44 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'factory_girl_rails' 2 | 3 | RSpec.configure do |config| 4 | 5 | config.include FactoryGirl::Syntax::Methods 6 | =begin 7 | # These two settings work together to allow you to limit a spec run 8 | # to individual examples or groups you care about by tagging them with 9 | # `:focus` metadata. When nothing is tagged with `:focus`, all examples 10 | # get run. 11 | config.filter_run :focus 12 | config.run_all_when_everything_filtered = true 13 | 14 | # Many RSpec users commonly either run the entire suite or an individual 15 | # file, and it's useful to allow more verbose output when running an 16 | # individual spec file. 17 | if config.files_to_run.one? 18 | # Use the documentation formatter for detailed output, 19 | # unless a formatter has already been configured 20 | # (e.g. via a command-line flag). 21 | config.default_formatter = 'doc' 22 | end 23 | 24 | # Print the 10 slowest examples and example groups at the 25 | # end of the spec run, to help surface which specs are running 26 | # particularly slow. 27 | config.profile_examples = 10 28 | 29 | # Run specs in random order to surface order dependencies. If you find an 30 | # order dependency and want to debug it, you can fix the order by providing 31 | # the seed, which is printed after each run. 32 | # --seed 1234 33 | config.order = :random 34 | 35 | # Seed global randomization in this process using the `--seed` CLI option. 36 | # Setting this allows you to use `--seed` to deterministically reproduce 37 | # test failures related to randomization by passing the same `--seed` value 38 | # as the one that triggered the failure. 39 | Kernel.srand config.seed 40 | 41 | # rspec-expectations config goes here. You can use an alternate 42 | # assertion/expectation library such as wrong or the stdlib/minitest 43 | # assertions if you prefer. 44 | config.expect_with :rspec do |expectations| 45 | # Enable only the newer, non-monkey-patching expect syntax. 46 | # For more details, see: 47 | # - http://myronmars.to/n/dev-blog/2012/06/rspecs-new-expectation-syntax 48 | expectations.syntax = :expect 49 | end 50 | 51 | # rspec-mocks config goes here. You can use an alternate test double 52 | # library (such as bogus or mocha) by changing the `mock_with` option here. 53 | config.mock_with :rspec do |mocks| 54 | # Enable only the newer, non-monkey-patching expect syntax. 55 | # For more details, see: 56 | # - http://teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 57 | mocks.syntax = :expect 58 | 59 | # Prevents you from mocking or stubbing a method that does not exist on 60 | # a real object. This is generally recommended. 61 | mocks.verify_partial_doubles = true 62 | end 63 | =end 64 | end 65 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/vendor/assets/javascripts/.keep -------------------------------------------------------------------------------- /vendor/assets/javascripts/keymaster.js: -------------------------------------------------------------------------------- 1 | // keymaster.js 2 | // (c) 2011-2013 Thomas Fuchs 3 | // keymaster.js may be freely distributed under the MIT license. 4 | 5 | ;(function(global){ 6 | var k, 7 | _handlers = {}, 8 | _mods = { 16: false, 18: false, 17: false, 91: false }, 9 | _scope = 'all', 10 | // modifier keys 11 | _MODIFIERS = { 12 | '⇧': 16, shift: 16, 13 | '⌥': 18, alt: 18, option: 18, 14 | '⌃': 17, ctrl: 17, control: 17, 15 | '⌘': 91, command: 91 16 | }, 17 | // special keys 18 | _MAP = { 19 | backspace: 8, tab: 9, clear: 12, 20 | enter: 13, 'return': 13, 21 | esc: 27, escape: 27, space: 32, 22 | left: 37, up: 38, 23 | right: 39, down: 40, 24 | del: 46, 'delete': 46, 25 | home: 36, end: 35, 26 | pageup: 33, pagedown: 34, 27 | ',': 188, '.': 190, '/': 191, 28 | '`': 192, '-': 189, '=': 187, 29 | ';': 186, '\'': 222, 30 | '[': 219, ']': 221, '\\': 220 31 | }, 32 | code = function(x){ 33 | return _MAP[x] || x.toUpperCase().charCodeAt(0); 34 | }, 35 | _downKeys = []; 36 | 37 | for(k=1;k<20;k++) _MAP['f'+k] = 111+k; 38 | 39 | // IE doesn't support Array#indexOf, so have a simple replacement 40 | function index(array, item){ 41 | var i = array.length; 42 | while(i--) if(array[i]===item) return i; 43 | return -1; 44 | } 45 | 46 | // for comparing mods before unassignment 47 | function compareArray(a1, a2) { 48 | if (a1.length != a2.length) return false; 49 | for (var i = 0; i < a1.length; i++) { 50 | if (a1[i] !== a2[i]) return false; 51 | } 52 | return true; 53 | } 54 | 55 | var modifierMap = { 56 | 16:'shiftKey', 57 | 18:'altKey', 58 | 17:'ctrlKey', 59 | 91:'metaKey' 60 | }; 61 | function updateModifierKey(event) { 62 | for(k in _mods) _mods[k] = event[modifierMap[k]]; 63 | }; 64 | 65 | // handle keydown event 66 | function dispatch(event) { 67 | var key, handler, k, i, modifiersMatch, scope; 68 | key = event.keyCode; 69 | 70 | if (index(_downKeys, key) == -1) { 71 | _downKeys.push(key); 72 | } 73 | 74 | // if a modifier key, set the key. property to true and return 75 | if(key == 93 || key == 224) key = 91; // right command on webkit, command on Gecko 76 | if(key in _mods) { 77 | _mods[key] = true; 78 | // 'assignKey' from inside this closure is exported to window.key 79 | for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = true; 80 | return; 81 | } 82 | updateModifierKey(event); 83 | 84 | // see if we need to ignore the keypress (filter() can can be overridden) 85 | // by default ignore key presses if a select, textarea, or input is focused 86 | if(!assignKey.filter.call(this, event)) return; 87 | 88 | // abort if no potentially matching shortcuts found 89 | if (!(key in _handlers)) return; 90 | 91 | scope = getScope(); 92 | 93 | // for each potential shortcut 94 | for (i = 0; i < _handlers[key].length; i++) { 95 | handler = _handlers[key][i]; 96 | 97 | // see if it's in the current scope 98 | if(handler.scope == scope || handler.scope == 'all'){ 99 | // check if modifiers match if any 100 | modifiersMatch = handler.mods.length > 0; 101 | for(k in _mods) 102 | if((!_mods[k] && index(handler.mods, +k) > -1) || 103 | (_mods[k] && index(handler.mods, +k) == -1)) modifiersMatch = false; 104 | // call the handler and stop the event if neccessary 105 | if((handler.mods.length == 0 && !_mods[16] && !_mods[18] && !_mods[17] && !_mods[91]) || modifiersMatch){ 106 | if(handler.method(event, handler)===false){ 107 | if(event.preventDefault) event.preventDefault(); 108 | else event.returnValue = false; 109 | if(event.stopPropagation) event.stopPropagation(); 110 | if(event.cancelBubble) event.cancelBubble = true; 111 | } 112 | } 113 | } 114 | } 115 | }; 116 | 117 | // unset modifier keys on keyup 118 | function clearModifier(event){ 119 | var key = event.keyCode, k, 120 | i = index(_downKeys, key); 121 | 122 | // remove key from _downKeys 123 | if (i >= 0) { 124 | _downKeys.splice(i, 1); 125 | } 126 | 127 | if(key == 93 || key == 224) key = 91; 128 | if(key in _mods) { 129 | _mods[key] = false; 130 | for(k in _MODIFIERS) if(_MODIFIERS[k] == key) assignKey[k] = false; 131 | } 132 | }; 133 | 134 | function resetModifiers() { 135 | for(k in _mods) _mods[k] = false; 136 | for(k in _MODIFIERS) assignKey[k] = false; 137 | }; 138 | 139 | // parse and assign shortcut 140 | function assignKey(key, scope, method){ 141 | var keys, mods; 142 | keys = getKeys(key); 143 | if (method === undefined) { 144 | method = scope; 145 | scope = 'all'; 146 | } 147 | 148 | // for each shortcut 149 | for (var i = 0; i < keys.length; i++) { 150 | // set modifier keys if any 151 | mods = []; 152 | key = keys[i].split('+'); 153 | if (key.length > 1){ 154 | mods = getMods(key); 155 | key = [key[key.length-1]]; 156 | } 157 | // convert to keycode and... 158 | key = key[0] 159 | key = code(key); 160 | // ...store handler 161 | if (!(key in _handlers)) _handlers[key] = []; 162 | _handlers[key].push({ shortcut: keys[i], scope: scope, method: method, key: keys[i], mods: mods }); 163 | } 164 | }; 165 | 166 | // unbind all handlers for given key in current scope 167 | function unbindKey(key, scope) { 168 | var multipleKeys, keys, 169 | mods = [], 170 | i, j, obj; 171 | 172 | multipleKeys = getKeys(key); 173 | 174 | for (j = 0; j < multipleKeys.length; j++) { 175 | keys = multipleKeys[j].split('+'); 176 | 177 | if (keys.length > 1) { 178 | mods = getMods(keys); 179 | key = keys[keys.length - 1]; 180 | } 181 | 182 | key = code(key); 183 | 184 | if (scope === undefined) { 185 | scope = getScope(); 186 | } 187 | if (!_handlers[key]) { 188 | return; 189 | } 190 | for (i = 0; i < _handlers[key].length; i++) { 191 | obj = _handlers[key][i]; 192 | // only clear handlers if correct scope and mods match 193 | if (obj.scope === scope && compareArray(obj.mods, mods)) { 194 | _handlers[key][i] = {}; 195 | } 196 | } 197 | } 198 | }; 199 | 200 | // Returns true if the key with code 'keyCode' is currently down 201 | // Converts strings into key codes. 202 | function isPressed(keyCode) { 203 | if (typeof(keyCode)=='string') { 204 | keyCode = code(keyCode); 205 | } 206 | return index(_downKeys, keyCode) != -1; 207 | } 208 | 209 | function getPressedKeyCodes() { 210 | return _downKeys.slice(0); 211 | } 212 | 213 | function filter(event){ 214 | var tagName = (event.target || event.srcElement).tagName; 215 | // ignore keypressed in any elements that support keyboard data input 216 | return !(tagName == 'INPUT' || tagName == 'SELECT' || tagName == 'TEXTAREA'); 217 | } 218 | 219 | // initialize key. to false 220 | for(k in _MODIFIERS) assignKey[k] = false; 221 | 222 | // set current scope (default 'all') 223 | function setScope(scope){ _scope = scope || 'all' }; 224 | function getScope(){ return _scope || 'all' }; 225 | 226 | // delete all handlers for a given scope 227 | function deleteScope(scope){ 228 | var key, handlers, i; 229 | 230 | for (key in _handlers) { 231 | handlers = _handlers[key]; 232 | for (i = 0; i < handlers.length; ) { 233 | if (handlers[i].scope === scope) handlers.splice(i, 1); 234 | else i++; 235 | } 236 | } 237 | }; 238 | 239 | // abstract key logic for assign and unassign 240 | function getKeys(key) { 241 | var keys; 242 | key = key.replace(/\s/g, ''); 243 | keys = key.split(','); 244 | if ((keys[keys.length - 1]) == '') { 245 | keys[keys.length - 2] += ','; 246 | } 247 | return keys; 248 | } 249 | 250 | // abstract mods logic for assign and unassign 251 | function getMods(key) { 252 | var mods = key.slice(0, key.length - 1); 253 | for (var mi = 0; mi < mods.length; mi++) 254 | mods[mi] = _MODIFIERS[mods[mi]]; 255 | return mods; 256 | } 257 | 258 | // cross-browser events 259 | function addEvent(object, event, method) { 260 | if (object.addEventListener) 261 | object.addEventListener(event, method, false); 262 | else if(object.attachEvent) 263 | object.attachEvent('on'+event, function(){ method(window.event) }); 264 | }; 265 | 266 | // set the handlers globally on document 267 | addEvent(document, 'keydown', function(event) { dispatch(event) }); // Passing _scope to a callback to ensure it remains the same by execution. Fixes #48 268 | addEvent(document, 'keyup', clearModifier); 269 | 270 | // reset modifiers to false whenever the window is (re)focused. 271 | addEvent(window, 'focus', resetModifiers); 272 | 273 | // store previously defined key 274 | var previousKey = global.key; 275 | 276 | // restore previously defined key and return reference to our key object 277 | function noConflict() { 278 | var k = global.key; 279 | global.key = previousKey; 280 | return k; 281 | } 282 | 283 | // set window.key and window.key.set/get/deleteScope, and the default filter 284 | global.key = assignKey; 285 | global.key.setScope = setScope; 286 | global.key.getScope = getScope; 287 | global.key.deleteScope = deleteScope; 288 | global.key.filter = filter; 289 | global.key.isPressed = isPressed; 290 | global.key.getPressedKeyCodes = getPressedKeyCodes; 291 | global.key.noConflict = noConflict; 292 | global.key.unbind = unbindKey; 293 | 294 | if(typeof module !== 'undefined') module.exports = assignKey; 295 | 296 | })(this); 297 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daniel-light/workflowy-clone/454358cc7ca9b4055ded0428de84b799fd6d59ca/vendor/assets/stylesheets/.keep --------------------------------------------------------------------------------