diff --git a/.gitignore b/.gitignore index a2b03a61..52428f17 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ #assety stuff realtime/node_modules public/assets +public/metamaps_mobile vendor/ #secrets and config @@ -19,6 +20,8 @@ vendor/ log/*.log tmp +coverage + .DS_Store */.DS_Store .DS_Store? diff --git a/.simplecov b/.simplecov new file mode 100644 index 00000000..b81ebfeb --- /dev/null +++ b/.simplecov @@ -0,0 +1,3 @@ +if ENV['COVERAGE'] == 'on' + SimpleCov.start 'rails' +end diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..58bf9d25 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +sudo: false +language: ruby +rvm: + - 2.1.3 +before_script: + - export RAILS_ENV=test + - cp .example-env .env + - bundle exec rake db:create + - bundle exec rake db:schema:load diff --git a/Gemfile b/Gemfile index b4e3bdf6..1a129d7b 100644 --- a/Gemfile +++ b/Gemfile @@ -6,7 +6,9 @@ gem 'rails', '4.2.4' gem 'devise' gem 'redis' gem 'pg' -gem 'cancancan' +gem 'pundit' +gem 'pundit_extra' +gem 'doorkeeper' gem 'formula' gem 'formtastic' gem 'json' @@ -15,6 +17,11 @@ gem 'best_in_place' #in-place editing gem 'kaminari' # pagination gem 'uservoice-ruby' gem 'dotenv' +gem 'snorlax' +gem 'httparty' +gem 'active_model_serializers', '~> 0.8.1' +gem 'delayed_job', '~> 4.0.2' +gem 'delayed_job_active_record', '~> 4.0.1' gem 'paperclip' gem 'aws-sdk', '< 2.0' @@ -42,6 +49,8 @@ group :test do gem 'rspec-rails' gem 'factory_girl_rails' gem 'shoulda-matchers' + gem 'simplecov', require: false + gem 'json-schema' end group :production do #this is used on heroku diff --git a/Gemfile.lock b/Gemfile.lock index 5525a8cc..6a522642 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -20,6 +20,8 @@ GEM erubis (~> 2.7.0) rails-dom-testing (~> 1.0, >= 1.0.5) rails-html-sanitizer (~> 1.0, >= 1.0.2) + active_model_serializers (0.8.3) + activemodel (>= 3.0) activejob (4.2.4) activesupport (= 4.2.4) globalid (>= 0.3.0) @@ -36,14 +38,15 @@ GEM minitest (~> 5.1) thread_safe (~> 0.3, >= 0.3.4) tzinfo (~> 1.1) + addressable (2.3.8) arel (6.0.3) aws-sdk (1.66.0) aws-sdk-v1 (= 1.66.0) aws-sdk-v1 (1.66.0) json (~> 1.4) nokogiri (>= 1.4.4) - bcrypt (3.1.10) - best_in_place (3.0.3) + bcrypt (3.1.11) + best_in_place (3.1.0) actionpack (>= 3.2) railties (>= 3.2) better_errors (2.1.1) @@ -53,24 +56,27 @@ GEM binding_of_caller (0.7.2) debug_inspector (>= 0.0.1) builder (3.2.2) - byebug (5.0.0) - columnize (= 0.9.0) - cancancan (1.13.1) + byebug (8.2.2) climate_control (0.0.3) activesupport (>= 3.0) - cocaine (0.5.7) + cocaine (0.5.8) climate_control (>= 0.0.3, < 1.0) - coderay (1.1.0) - coffee-rails (4.1.0) + coderay (1.1.1) + coffee-rails (4.1.1) coffee-script (>= 2.2.0) - railties (>= 4.0.0, < 5.0) + railties (>= 4.0.0, < 5.1.x) coffee-script (2.4.1) coffee-script-source execjs - coffee-script-source (1.9.1.1) - columnize (0.9.0) + coffee-script-source (1.10.0) + concurrent-ruby (1.0.1) debug_inspector (0.0.2) - devise (3.5.2) + delayed_job (4.0.6) + activesupport (>= 3.0, < 5.0) + delayed_job_active_record (4.0.3) + activerecord (>= 3.0, < 5.0) + delayed_job (>= 3.0, < 4.1) + devise (3.5.6) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 3.2.6, < 5) @@ -78,13 +84,16 @@ GEM thread_safe (~> 0.1) warden (~> 1.2.3) diff-lcs (1.2.5) - dotenv (2.0.2) + docile (1.1.5) + doorkeeper (3.1.0) + railties (>= 3.2) + dotenv (2.1.0) erubis (2.7.0) execjs (2.6.0) ezcrypto (0.7.2) factory_girl (4.5.0) activesupport (>= 3.0.0) - factory_girl_rails (4.5.0) + factory_girl_rails (4.6.0) factory_girl (~> 4.5.0) railties (>= 3.0.0) formtastic (3.1.3) @@ -93,50 +102,61 @@ GEM rails (> 3.0.0) globalid (0.3.6) activesupport (>= 4.1.0) + httparty (0.13.7) + json (~> 1.8) + multi_xml (>= 0.5.2) i18n (0.7.0) - jbuilder (2.3.2) - activesupport (>= 3.0.0, < 5) + jbuilder (2.4.1) + activesupport (>= 3.0.0, < 5.1) multi_json (~> 1.2) - jquery-rails (4.0.5) - rails-dom-testing (~> 1.0) + jquery-rails (4.1.1) + rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) jquery-ui-rails (5.0.5) railties (>= 3.2.16) json (1.8.3) + json-schema (2.6.1) + addressable (~> 2.3.8) kaminari (0.16.3) actionpack (>= 3.0.0) activesupport (>= 3.0.0) loofah (2.0.3) nokogiri (>= 1.5.9) - mail (2.6.3) - mime-types (>= 1.16, < 3) + mail (2.6.4) + mime-types (>= 1.16, < 4) method_source (0.8.2) - mime-types (2.6.2) + mime-types (3.0) + mime-types-data (~> 3.2015) + mime-types-data (3.2016.0221) mimemagic (0.3.0) - mini_portile (0.6.2) - minitest (5.8.2) + mini_portile2 (2.0.0) + minitest (5.8.4) multi_json (1.11.2) - nokogiri (1.6.6.2) - mini_portile (~> 0.6.0) - oauth (0.4.7) + multi_xml (0.5.5) + nokogiri (1.6.7.2) + mini_portile2 (~> 2.0.0.rc2) + oauth (0.5.1) orm_adapter (0.5.0) - paperclip (4.3.1) + paperclip (4.3.5) activemodel (>= 3.2.0) activesupport (>= 3.2.0) cocaine (~> 0.5.5) mime-types mimemagic (= 0.3.0) - pg (0.18.3) + pg (0.18.4) pry (0.10.3) coderay (~> 1.1.0) method_source (~> 0.8.1) slop (~> 3.4) - pry-byebug (3.2.0) - byebug (~> 5.0) + pry-byebug (3.3.0) + byebug (~> 8.0) pry (~> 0.10) pry-rails (0.3.4) pry (>= 0.9.10) + pundit (1.1.0) + activesupport (>= 3.0.0) + pundit_extra (0.1.1) quiet_assets (1.1.0) railties (>= 3.1, < 5.0) rack (1.6.4) @@ -159,61 +179,69 @@ GEM activesupport (>= 4.2.0.beta, < 5.0) nokogiri (~> 1.6.0) rails-deprecated_sanitizer (>= 1.0.1) - rails-html-sanitizer (1.0.2) + rails-html-sanitizer (1.0.3) loofah (~> 2.0) rails3-jquery-autocomplete (1.0.15) rails (>= 3.2) rails_12factor (0.0.3) rails_serve_static_assets rails_stdout_logging - rails_serve_static_assets (0.0.4) + rails_serve_static_assets (0.0.5) rails_stdout_logging (0.0.4) railties (4.2.4) actionpack (= 4.2.4) activesupport (= 4.2.4) rake (>= 0.8.7) thor (>= 0.18.1, < 2.0) - rake (10.4.2) - redis (3.2.1) - responders (2.1.0) - railties (>= 4.2.0, < 5) - rspec-core (3.3.2) - rspec-support (~> 3.3.0) - rspec-expectations (3.3.1) + rake (11.1.1) + redis (3.2.2) + responders (2.1.1) + railties (>= 4.2.0, < 5.1) + rspec-core (3.4.4) + rspec-support (~> 3.4.0) + rspec-expectations (3.4.0) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.3.0) - rspec-mocks (3.3.2) + rspec-support (~> 3.4.0) + rspec-mocks (3.4.1) diff-lcs (>= 1.2.0, < 2.0) - rspec-support (~> 3.3.0) - rspec-rails (3.3.3) + rspec-support (~> 3.4.0) + rspec-rails (3.4.2) actionpack (>= 3.0, < 4.3) activesupport (>= 3.0, < 4.3) railties (>= 3.0, < 4.3) - rspec-core (~> 3.3.0) - rspec-expectations (~> 3.3.0) - rspec-mocks (~> 3.3.0) - rspec-support (~> 3.3.0) - rspec-support (3.3.0) - sass (3.4.19) + rspec-core (~> 3.4.0) + rspec-expectations (~> 3.4.0) + rspec-mocks (~> 3.4.0) + rspec-support (~> 3.4.0) + rspec-support (3.4.1) + sass (3.4.21) sass-rails (5.0.4) railties (>= 4.0.0, < 5.0) sass (~> 3.1) sprockets (>= 2.8, < 4.0) sprockets-rails (>= 2.0, < 4.0) tilt (>= 1.1, < 3) - shoulda-matchers (3.0.1) + shoulda-matchers (3.1.1) activesupport (>= 4.0.0) + simplecov (0.11.2) + docile (~> 1.1.0) + json (~> 1.8) + simplecov-html (~> 0.10.0) + simplecov-html (0.10.0) slop (3.6.0) - sprockets (3.4.0) + snorlax (0.1.5) + rails (> 4.1) + sprockets (3.5.2) + concurrent-ruby (~> 1.0) rack (> 1, < 3) - sprockets-rails (2.3.3) - actionpack (>= 3.0) - activesupport (>= 3.0) - sprockets (>= 2.8, < 4.0) + sprockets-rails (3.0.4) + actionpack (>= 4.0) + activesupport (>= 4.0) + sprockets (>= 3.0.0) thor (0.19.1) thread_safe (0.3.5) - tilt (2.0.1) - tunemygc (1.0.61) + tilt (2.0.2) + tunemygc (1.0.65) tzinfo (1.2.2) thread_safe (~> 0.1) uglifier (2.7.2) @@ -223,33 +251,40 @@ GEM ezcrypto (>= 0.7.2) json (>= 1.7.5) oauth (>= 0.4.7) - warden (1.2.3) + warden (1.2.6) rack (>= 1.0) PLATFORMS ruby DEPENDENCIES + active_model_serializers (~> 0.8.1) aws-sdk (< 2.0) best_in_place better_errors binding_of_caller - cancancan coffee-rails + delayed_job (~> 4.0.2) + delayed_job_active_record (~> 4.0.1) devise + doorkeeper dotenv factory_girl_rails formtastic formula + httparty jbuilder jquery-rails jquery-ui-rails json + json-schema kaminari paperclip pg pry-byebug pry-rails + pundit + pundit_extra quiet_assets rails (= 4.2.4) rails3-jquery-autocomplete @@ -258,9 +293,11 @@ DEPENDENCIES rspec-rails sass-rails shoulda-matchers + simplecov + snorlax tunemygc uglifier uservoice-ruby BUNDLED WITH - 1.10.6 + 1.11.2 diff --git a/Procfile b/Procfile index 443f2e35..e00c3019 100644 --- a/Procfile +++ b/Procfile @@ -1 +1,3 @@ web: bundle exec rails server -p $PORT +worker: bundle exec rake jobs:work + diff --git a/README.md b/README.md index 5cfd3a37..cf871b82 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ Metamaps ======= [![Join the chat at https://gitter.im/metamaps/metamaps_gen002](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/metamaps/metamaps_gen002?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) -![Build Status](https://jenkins.devinhoward.ca/job/metamaps_gen002.develop/badge/icon) +[![Build Status](https://travis-ci.org/metamaps/metamaps_gen002.svg)](https://travis-ci.org/metamaps/metamaps_gen002) -Welcome to the Metamaps GitHub repo. +Welcome to the Metamaps GitHub repo. ## About @@ -12,7 +12,7 @@ Metamaps is a free and AGPL open source technology for changemakers, innovators, You can find a version of this software running at [metamaps.cc][site-beta], where the technology is being tested in a private beta. -Metamaps is created and maintained by a distributed, nomadic community comprised of technologists, artists and storytellers. You can get in touch with us at team@metamaps.cc or @metamapps on twitter. +Metamaps is created and maintained by a distributed, nomadic community comprised of technologists, artists and storytellers. You can get in touch with us at team@metamaps.cc or @metamapps on twitter. To get connected with the community interested in Metamaps, join our [Google+ community][community]. @@ -52,7 +52,7 @@ We haven't figured out Vagrant for Windows yet, but we have a set of manual inst ## Contributing -Cloning this repository directly is primarily for those wishing to contribute to our codebase. Check out our [contributing instructions][contributing] to get involved. +Cloning this repository directly is primarily for those wishing to contribute to our codebase. Check out our [contributing instructions][contributing] to get involved. ## Community diff --git a/Vagrantfile b/Vagrantfile index dad6c7c7..c9ea9363 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -16,7 +16,6 @@ sudo apt-get install nodejs -y sudo apt-get install npm -y sudo apt-get install postgresql -y sudo apt-get install libpq-dev -y -sudo apt-get install redis-server -y # get imagemagick sudo apt-get install imagemagick --fix-missing diff --git a/app/assets/images/audio_sprite.png b/app/assets/images/audio_sprite.png new file mode 100644 index 00000000..00ff9f78 Binary files /dev/null and b/app/assets/images/audio_sprite.png differ diff --git a/app/assets/images/camera_sprite.png b/app/assets/images/camera_sprite.png new file mode 100644 index 00000000..aa808e8c Binary files /dev/null and b/app/assets/images/camera_sprite.png differ diff --git a/app/assets/images/chat32.png b/app/assets/images/chat32.png new file mode 100644 index 00000000..e2f084f4 Binary files /dev/null and b/app/assets/images/chat32.png differ diff --git a/app/assets/images/cursor_sprite.png b/app/assets/images/cursor_sprite.png new file mode 100644 index 00000000..ec49fec8 Binary files /dev/null and b/app/assets/images/cursor_sprite.png differ diff --git a/app/assets/images/default_profile.png b/app/assets/images/default_profile.png new file mode 100644 index 00000000..d6fa4c31 Binary files /dev/null and b/app/assets/images/default_profile.png differ diff --git a/app/assets/images/ellipsis.gif b/app/assets/images/ellipsis.gif new file mode 100644 index 00000000..28d4982f Binary files /dev/null and b/app/assets/images/ellipsis.gif differ diff --git a/app/assets/images/invitepeer16.png b/app/assets/images/invitepeer16.png new file mode 100644 index 00000000..73ef64be Binary files /dev/null and b/app/assets/images/invitepeer16.png differ diff --git a/app/assets/images/junto.png b/app/assets/images/junto.png new file mode 100644 index 00000000..5f9e3c3c Binary files /dev/null and b/app/assets/images/junto.png differ diff --git a/app/assets/images/junto_spinner_dark.gif b/app/assets/images/junto_spinner_dark.gif new file mode 100644 index 00000000..945de492 Binary files /dev/null and b/app/assets/images/junto_spinner_dark.gif differ diff --git a/app/assets/images/sound_sprite.png b/app/assets/images/sound_sprite.png new file mode 100644 index 00000000..9ebac6e3 Binary files /dev/null and b/app/assets/images/sound_sprite.png differ diff --git a/app/assets/images/sounds/sounds.mp3 b/app/assets/images/sounds/sounds.mp3 new file mode 100644 index 00000000..04f65df0 Binary files /dev/null and b/app/assets/images/sounds/sounds.mp3 differ diff --git a/app/assets/images/sounds/sounds.ogg b/app/assets/images/sounds/sounds.ogg new file mode 100644 index 00000000..aaf4e774 Binary files /dev/null and b/app/assets/images/sounds/sounds.ogg differ diff --git a/app/assets/images/tray_tab.png b/app/assets/images/tray_tab.png new file mode 100644 index 00000000..06d7002d Binary files /dev/null and b/app/assets/images/tray_tab.png differ diff --git a/app/assets/images/video_sprite.png b/app/assets/images/video_sprite.png new file mode 100644 index 00000000..6ed4e1b3 Binary files /dev/null and b/app/assets/images/video_sprite.png differ diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 6ebaedbd..2fd7ab6a 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -1,28 +1,32 @@ -// This is a manifest file that'll be compiled into application.js, which will include all the files -// listed below. -// -// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, -// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. -// -// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the -// the compiled file. -// -// WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD -// GO AFTER THE REQUIRES BELOW. -// -//= require jquery -//= require jquery-ui -//= require jquery_ujs -//= require ./orderedLibraries/underscore -//= require ./orderedLibraries/backbone -//= require_directory ./lib -//= require ./src/Metamaps.GlobalUI -//= require ./src/Metamaps.Router -//= require ./src/Metamaps.Backbone -//= require ./src/Metamaps.Views -//= require ./src/JIT -//= require ./src/Metamaps -//= require ./src/Metamaps.JIT -//= require_directory ./shims -//= require_directory ./require -//= require_directory ./famous \ No newline at end of file +// This is a manifest file that'll be compiled into application.js, which will include all the files +// listed below. +// +// Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, +// or vendor/assets/javascripts of plugins, if any, can be referenced here using a relative path. +// +// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +// the compiled file. +// +// WARNING: THE FIRST BLANK LINE MARKS THE END OF WHAT'S TO BE PROCESSED, ANY BLANK LINE SHOULD +// GO AFTER THE REQUIRES BELOW. +// +//= require jquery +//= require jquery-ui +//= require jquery_ujs +//= require ./orderedLibraries/underscore +//= require ./orderedLibraries/backbone +//= require_directory ./lib +//= require ./src/Metamaps.GlobalUI +//= require ./src/Metamaps.Router +//= require ./src/Metamaps.Backbone +//= require ./src/Metamaps.Views +//= require ./src/views/chatView +//= require ./src/views/videoView +//= require ./src/views/room +//= require ./src/JIT +//= require ./src/Metamaps +//= require ./src/Metamaps.Import +//= require ./src/Metamaps.JIT +//= require_directory ./shims +//= require_directory ./require +//= require_directory ./famous diff --git a/app/assets/javascripts/lib/Autolinker.js b/app/assets/javascripts/lib/Autolinker.js new file mode 100644 index 00000000..6f363d4c --- /dev/null +++ b/app/assets/javascripts/lib/Autolinker.js @@ -0,0 +1,2756 @@ +(function (root, factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module unless amdModuleId is set + define([], function () { + return (root['Autolinker'] = factory()); + }); + } else if (typeof exports === 'object') { + // Node. Does not work with strict CommonJS, but + // only CommonJS-like environments that support module.exports, + // like Node. + module.exports = factory(); + } else { + root['Autolinker'] = factory(); + } +}(this, function () { + +/*! + * Autolinker.js + * 0.17.1 + * + * Copyright(c) 2015 Gregory Jacobs + * MIT Licensed. http://www.opensource.org/licenses/mit-license.php + * + * https://github.com/gregjacobs/Autolinker.js + */ +/** + * @class Autolinker + * @extends Object + * + * Utility class used to process a given string of text, and wrap the matches in + * the appropriate anchor (<a>) tags to turn them into links. + * + * Any of the configuration options may be provided in an Object (map) provided + * to the Autolinker constructor, which will configure how the {@link #link link()} + * method will process the links. + * + * For example: + * + * var autolinker = new Autolinker( { + * newWindow : false, + * truncate : 30 + * } ); + * + * var html = autolinker.link( "Joe went to www.yahoo.com" ); + * // produces: 'Joe went to yahoo.com' + * + * + * The {@link #static-link static link()} method may also be used to inline options into a single call, which may + * be more convenient for one-off uses. For example: + * + * var html = Autolinker.link( "Joe went to www.yahoo.com", { + * newWindow : false, + * truncate : 30 + * } ); + * // produces: 'Joe went to yahoo.com' + * + * + * ## Custom Replacements of Links + * + * If the configuration options do not provide enough flexibility, a {@link #replaceFn} + * may be provided to fully customize the output of Autolinker. This function is + * called once for each URL/Email/Phone#/Twitter Handle/Hashtag match that is + * encountered. + * + * For example: + * + * var input = "..."; // string with URLs, Email Addresses, Phone #s, Twitter Handles, and Hashtags + * + * var linkedText = Autolinker.link( input, { + * replaceFn : function( autolinker, match ) { + * console.log( "href = ", match.getAnchorHref() ); + * console.log( "text = ", match.getAnchorText() ); + * + * switch( match.getType() ) { + * case 'url' : + * console.log( "url: ", match.getUrl() ); + * + * if( match.getUrl().indexOf( 'mysite.com' ) === -1 ) { + * var tag = autolinker.getTagBuilder().build( match ); // returns an `Autolinker.HtmlTag` instance, which provides mutator methods for easy changes + * tag.setAttr( 'rel', 'nofollow' ); + * tag.addClass( 'external-link' ); + * + * return tag; + * + * } else { + * return true; // let Autolinker perform its normal anchor tag replacement + * } + * + * case 'email' : + * var email = match.getEmail(); + * console.log( "email: ", email ); + * + * if( email === "my@own.address" ) { + * return false; // don't auto-link this particular email address; leave as-is + * } else { + * return; // no return value will have Autolinker perform its normal anchor tag replacement (same as returning `true`) + * } + * + * case 'phone' : + * var phoneNumber = match.getPhoneNumber(); + * console.log( phoneNumber ); + * + * return '' + phoneNumber + ''; + * + * case 'twitter' : + * var twitterHandle = match.getTwitterHandle(); + * console.log( twitterHandle ); + * + * return '' + twitterHandle + ''; + * + * case 'hashtag' : + * var hashtag = match.getHashtag(); + * console.log( hashtag ); + * + * return '' + hashtag + ''; + * } + * } + * } ); + * + * + * The function may return the following values: + * + * - `true` (Boolean): Allow Autolinker to replace the match as it normally would. + * - `false` (Boolean): Do not replace the current match at all - leave as-is. + * - Any String: If a string is returned from the function, the string will be used directly as the replacement HTML for + * the match. + * - An {@link Autolinker.HtmlTag} instance, which can be used to build/modify an HTML tag before writing out its HTML text. + * + * @constructor + * @param {Object} [config] The configuration options for the Autolinker instance, specified in an Object (map). + */ +var Autolinker = function( cfg ) { + Autolinker.Util.assign( this, cfg ); // assign the properties of `cfg` onto the Autolinker instance. Prototype properties will be used for missing configs. + + // Validate the value of the `hashtag` cfg. + var hashtag = this.hashtag; + if( hashtag !== false && hashtag !== 'twitter' && hashtag !== 'facebook' ) { + throw new Error( "invalid `hashtag` cfg - see docs" ); + } +}; + +Autolinker.prototype = { + constructor : Autolinker, // fix constructor property + + /** + * @cfg {Boolean} urls + * + * `true` if miscellaneous URLs should be automatically linked, `false` if they should not be. + */ + urls : true, + + /** + * @cfg {Boolean} email + * + * `true` if email addresses should be automatically linked, `false` if they should not be. + */ + email : true, + + /** + * @cfg {Boolean} twitter + * + * `true` if Twitter handles ("@example") should be automatically linked, `false` if they should not be. + */ + twitter : true, + + /** + * @cfg {Boolean} phone + * + * `true` if Phone numbers ("(555)555-5555") should be automatically linked, `false` if they should not be. + */ + phone: true, + + /** + * @cfg {Boolean/String} hashtag + * + * A string for the service name to have hashtags (ex: "#myHashtag") + * auto-linked to. The currently-supported values are: + * + * - 'twitter' + * - 'facebook' + * + * Pass `false` to skip auto-linking of hashtags. + */ + hashtag : false, + + /** + * @cfg {Boolean} newWindow + * + * `true` if the links should open in a new window, `false` otherwise. + */ + newWindow : true, + + /** + * @cfg {Boolean} stripPrefix + * + * `true` if 'http://' or 'https://' and/or the 'www.' should be stripped + * from the beginning of URL links' text, `false` otherwise. + */ + stripPrefix : true, + + /** + * @cfg {Number} truncate + * + * A number for how many characters long matched text should be truncated to inside the text of + * a link. If the matched text is over this number of characters, it will be truncated to this length by + * adding a two period ellipsis ('..') to the end of the string. + * + * For example: A url like 'http://www.yahoo.com/some/long/path/to/a/file' truncated to 25 characters might look + * something like this: 'yahoo.com/some/long/pat..' + */ + truncate : undefined, + + /** + * @cfg {String} className + * + * A CSS class name to add to the generated links. This class will be added to all links, as well as this class + * plus match suffixes for styling url/email/phone/twitter/hashtag links differently. + * + * For example, if this config is provided as "myLink", then: + * + * - URL links will have the CSS classes: "myLink myLink-url" + * - Email links will have the CSS classes: "myLink myLink-email", and + * - Twitter links will have the CSS classes: "myLink myLink-twitter" + * - Phone links will have the CSS classes: "myLink myLink-phone" + * - Hashtag links will have the CSS classes: "myLink myLink-hashtag" + */ + className : "", + + /** + * @cfg {Function} replaceFn + * + * A function to individually process each match found in the input string. + * + * See the class's description for usage. + * + * This function is called with the following parameters: + * + * @cfg {Autolinker} replaceFn.autolinker The Autolinker instance, which may be used to retrieve child objects from (such + * as the instance's {@link #getTagBuilder tag builder}). + * @cfg {Autolinker.match.Match} replaceFn.match The Match instance which can be used to retrieve information about the + * match that the `replaceFn` is currently processing. See {@link Autolinker.match.Match} subclasses for details. + */ + + + /** + * @private + * @property {Autolinker.htmlParser.HtmlParser} htmlParser + * + * The HtmlParser instance used to skip over HTML tags, while finding text nodes to process. This is lazily instantiated + * in the {@link #getHtmlParser} method. + */ + htmlParser : undefined, + + /** + * @private + * @property {Autolinker.matchParser.MatchParser} matchParser + * + * The MatchParser instance used to find matches in the text nodes of an input string passed to + * {@link #link}. This is lazily instantiated in the {@link #getMatchParser} method. + */ + matchParser : undefined, + + /** + * @private + * @property {Autolinker.AnchorTagBuilder} tagBuilder + * + * The AnchorTagBuilder instance used to build match replacement anchor tags. Note: this is lazily instantiated + * in the {@link #getTagBuilder} method. + */ + tagBuilder : undefined, + + /** + * Automatically links URLs, Email addresses, Phone numbers, Twitter + * handles, and Hashtags found in the given chunk of HTML. Does not link + * URLs found within HTML tags. + * + * For instance, if given the text: `You should go to http://www.yahoo.com`, + * then the result will be `You should go to + * <a href="http://www.yahoo.com">http://www.yahoo.com</a>` + * + * This method finds the text around any HTML elements in the input + * `textOrHtml`, which will be the text that is processed. Any original HTML + * elements will be left as-is, as well as the text that is already wrapped + * in anchor (<a>) tags. + * + * @param {String} textOrHtml The HTML or text to autolink matches within + * (depending on if the {@link #urls}, {@link #email}, {@link #phone}, + * {@link #twitter}, and {@link #hashtag} options are enabled). + * @return {String} The HTML, with matches automatically linked. + */ + link : function( textOrHtml ) { + var htmlParser = this.getHtmlParser(), + htmlNodes = htmlParser.parse( textOrHtml ), + anchorTagStackCount = 0, // used to only process text around anchor tags, and any inner text/html they may have + resultHtml = []; + + for( var i = 0, len = htmlNodes.length; i < len; i++ ) { + var node = htmlNodes[ i ], + nodeType = node.getType(), + nodeText = node.getText(); + + if( nodeType === 'element' ) { + // Process HTML nodes in the input `textOrHtml` + if( node.getTagName() === 'a' ) { + if( !node.isClosing() ) { // it's the start tag + anchorTagStackCount++; + } else { // it's the end tag + anchorTagStackCount = Math.max( anchorTagStackCount - 1, 0 ); // attempt to handle extraneous tags by making sure the stack count never goes below 0 + } + } + resultHtml.push( nodeText ); // now add the text of the tag itself verbatim + + } else if( nodeType === 'entity' || nodeType === 'comment' ) { + resultHtml.push( nodeText ); // append HTML entity nodes (such as ' ') or HTML comments (such as '') verbatim + + } else { + // Process text nodes in the input `textOrHtml` + if( anchorTagStackCount === 0 ) { + // If we're not within an tag, process the text node to linkify + var linkifiedStr = this.linkifyStr( nodeText ); + resultHtml.push( linkifiedStr ); + + } else { + // `text` is within an tag, simply append the text - we do not want to autolink anything + // already within an ... tag + resultHtml.push( nodeText ); + } + } + } + + return resultHtml.join( "" ); + }, + + /** + * Process the text that lies in between HTML tags, performing the anchor + * tag replacements for the matches, and returns the string with the + * replacements made. + * + * This method does the actual wrapping of matches with anchor tags. + * + * @private + * @param {String} str The string of text to auto-link. + * @return {String} The text with anchor tags auto-filled. + */ + linkifyStr : function( str ) { + return this.getMatchParser().replace( str, this.createMatchReturnVal, this ); + }, + + + /** + * Creates the return string value for a given match in the input string, + * for the {@link #linkifyStr} method. + * + * This method handles the {@link #replaceFn}, if one was provided. + * + * @private + * @param {Autolinker.match.Match} match The Match object that represents the match. + * @return {String} The string that the `match` should be replaced with. This is usually the anchor tag string, but + * may be the `matchStr` itself if the match is not to be replaced. + */ + createMatchReturnVal : function( match ) { + // Handle a custom `replaceFn` being provided + var replaceFnResult; + if( this.replaceFn ) { + replaceFnResult = this.replaceFn.call( this, this, match ); // Autolinker instance is the context, and the first arg + } + + if( typeof replaceFnResult === 'string' ) { + return replaceFnResult; // `replaceFn` returned a string, use that + + } else if( replaceFnResult === false ) { + return match.getMatchedText(); // no replacement for the match + + } else if( replaceFnResult instanceof Autolinker.HtmlTag ) { + return replaceFnResult.toAnchorString(); + + } else { // replaceFnResult === true, or no/unknown return value from function + // Perform Autolinker's default anchor tag generation + var tagBuilder = this.getTagBuilder(), + anchorTag = tagBuilder.build( match ); // returns an Autolinker.HtmlTag instance + + return anchorTag.toAnchorString(); + } + }, + + + /** + * Lazily instantiates and returns the {@link #htmlParser} instance for this Autolinker instance. + * + * @protected + * @return {Autolinker.htmlParser.HtmlParser} + */ + getHtmlParser : function() { + var htmlParser = this.htmlParser; + + if( !htmlParser ) { + htmlParser = this.htmlParser = new Autolinker.htmlParser.HtmlParser(); + } + + return htmlParser; + }, + + + /** + * Lazily instantiates and returns the {@link #matchParser} instance for this Autolinker instance. + * + * @protected + * @return {Autolinker.matchParser.MatchParser} + */ + getMatchParser : function() { + var matchParser = this.matchParser; + + if( !matchParser ) { + matchParser = this.matchParser = new Autolinker.matchParser.MatchParser( { + urls : this.urls, + email : this.email, + twitter : this.twitter, + phone : this.phone, + hashtag : this.hashtag, + stripPrefix : this.stripPrefix + } ); + } + + return matchParser; + }, + + + /** + * Returns the {@link #tagBuilder} instance for this Autolinker instance, lazily instantiating it + * if it does not yet exist. + * + * This method may be used in a {@link #replaceFn} to generate the {@link Autolinker.HtmlTag HtmlTag} instance that + * Autolinker would normally generate, and then allow for modifications before returning it. For example: + * + * var html = Autolinker.link( "Test google.com", { + * replaceFn : function( autolinker, match ) { + * var tag = autolinker.getTagBuilder().build( match ); // returns an {@link Autolinker.HtmlTag} instance + * tag.setAttr( 'rel', 'nofollow' ); + * + * return tag; + * } + * } ); + * + * // generated html: + * // Test google.com + * + * @return {Autolinker.AnchorTagBuilder} + */ + getTagBuilder : function() { + var tagBuilder = this.tagBuilder; + + if( !tagBuilder ) { + tagBuilder = this.tagBuilder = new Autolinker.AnchorTagBuilder( { + newWindow : this.newWindow, + truncate : this.truncate, + className : this.className + } ); + } + + return tagBuilder; + } + +}; + + +/** + * Automatically links URLs, Email addresses, Phone Numbers, Twitter handles, + * and Hashtags found in the given chunk of HTML. Does not link URLs found + * within HTML tags. + * + * For instance, if given the text: `You should go to http://www.yahoo.com`, + * then the result will be `You should go to <a href="http://www.yahoo.com">http://www.yahoo.com</a>` + * + * Example: + * + * var linkedText = Autolinker.link( "Go to google.com", { newWindow: false } ); + * // Produces: "Go to google.com" + * + * @static + * @param {String} textOrHtml The HTML or text to find matches within (depending + * on if the {@link #urls}, {@link #email}, {@link #phone}, {@link #twitter}, + * and {@link #hashtag} options are enabled). + * @param {Object} [options] Any of the configuration options for the Autolinker + * class, specified in an Object (map). See the class description for an + * example call. + * @return {String} The HTML text, with matches automatically linked. + */ +Autolinker.link = function( textOrHtml, options ) { + var autolinker = new Autolinker( options ); + return autolinker.link( textOrHtml ); +}; + + +// Autolinker Namespaces +Autolinker.match = {}; +Autolinker.htmlParser = {}; +Autolinker.matchParser = {}; + +/*global Autolinker */ +/*jshint eqnull:true, boss:true */ +/** + * @class Autolinker.Util + * @singleton + * + * A few utility methods for Autolinker. + */ +Autolinker.Util = { + + /** + * @property {Function} abstractMethod + * + * A function object which represents an abstract method. + */ + abstractMethod : function() { throw "abstract"; }, + + + /** + * @private + * @property {RegExp} trimRegex + * + * The regular expression used to trim the leading and trailing whitespace + * from a string. + */ + trimRegex : /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, + + + /** + * Assigns (shallow copies) the properties of `src` onto `dest`. + * + * @param {Object} dest The destination object. + * @param {Object} src The source object. + * @return {Object} The destination object (`dest`) + */ + assign : function( dest, src ) { + for( var prop in src ) { + if( src.hasOwnProperty( prop ) ) { + dest[ prop ] = src[ prop ]; + } + } + + return dest; + }, + + + /** + * Extends `superclass` to create a new subclass, adding the `protoProps` to the new subclass's prototype. + * + * @param {Function} superclass The constructor function for the superclass. + * @param {Object} protoProps The methods/properties to add to the subclass's prototype. This may contain the + * special property `constructor`, which will be used as the new subclass's constructor function. + * @return {Function} The new subclass function. + */ + extend : function( superclass, protoProps ) { + var superclassProto = superclass.prototype; + + var F = function() {}; + F.prototype = superclassProto; + + var subclass; + if( protoProps.hasOwnProperty( 'constructor' ) ) { + subclass = protoProps.constructor; + } else { + subclass = function() { superclassProto.constructor.apply( this, arguments ); }; + } + + var subclassProto = subclass.prototype = new F(); // set up prototype chain + subclassProto.constructor = subclass; // fix constructor property + subclassProto.superclass = superclassProto; + + delete protoProps.constructor; // don't re-assign constructor property to the prototype, since a new function may have been created (`subclass`), which is now already there + Autolinker.Util.assign( subclassProto, protoProps ); + + return subclass; + }, + + + /** + * Truncates the `str` at `len - ellipsisChars.length`, and adds the `ellipsisChars` to the + * end of the string (by default, two periods: '..'). If the `str` length does not exceed + * `len`, the string will be returned unchanged. + * + * @param {String} str The string to truncate and add an ellipsis to. + * @param {Number} truncateLen The length to truncate the string at. + * @param {String} [ellipsisChars=..] The ellipsis character(s) to add to the end of `str` + * when truncated. Defaults to '..' + */ + ellipsis : function( str, truncateLen, ellipsisChars ) { + if( str.length > truncateLen ) { + ellipsisChars = ( ellipsisChars == null ) ? '..' : ellipsisChars; + str = str.substring( 0, truncateLen - ellipsisChars.length ) + ellipsisChars; + } + return str; + }, + + + /** + * Supports `Array.prototype.indexOf()` functionality for old IE (IE8 and below). + * + * @param {Array} arr The array to find an element of. + * @param {*} element The element to find in the array, and return the index of. + * @return {Number} The index of the `element`, or -1 if it was not found. + */ + indexOf : function( arr, element ) { + if( Array.prototype.indexOf ) { + return arr.indexOf( element ); + + } else { + for( var i = 0, len = arr.length; i < len; i++ ) { + if( arr[ i ] === element ) return i; + } + return -1; + } + }, + + + + /** + * Performs the functionality of what modern browsers do when `String.prototype.split()` is called + * with a regular expression that contains capturing parenthesis. + * + * For example: + * + * // Modern browsers: + * "a,b,c".split( /(,)/ ); // --> [ 'a', ',', 'b', ',', 'c' ] + * + * // Old IE (including IE8): + * "a,b,c".split( /(,)/ ); // --> [ 'a', 'b', 'c' ] + * + * This method emulates the functionality of modern browsers for the old IE case. + * + * @param {String} str The string to split. + * @param {RegExp} splitRegex The regular expression to split the input `str` on. The splitting + * character(s) will be spliced into the array, as in the "modern browsers" example in the + * description of this method. + * Note #1: the supplied regular expression **must** have the 'g' flag specified. + * Note #2: for simplicity's sake, the regular expression does not need + * to contain capturing parenthesis - it will be assumed that any match has them. + * @return {String[]} The split array of strings, with the splitting character(s) included. + */ + splitAndCapture : function( str, splitRegex ) { + if( !splitRegex.global ) throw new Error( "`splitRegex` must have the 'g' flag set" ); + + var result = [], + lastIdx = 0, + match; + + while( match = splitRegex.exec( str ) ) { + result.push( str.substring( lastIdx, match.index ) ); + result.push( match[ 0 ] ); // push the splitting char(s) + + lastIdx = match.index + match[ 0 ].length; + } + result.push( str.substring( lastIdx ) ); + + return result; + }, + + + /** + * Trims the leading and trailing whitespace from a string. + * + * @param {String} str The string to trim. + * @return {String} + */ + trim : function( str ) { + return str.replace( this.trimRegex, '' ); + } + +}; +/*global Autolinker */ +/*jshint boss:true */ +/** + * @class Autolinker.HtmlTag + * @extends Object + * + * Represents an HTML tag, which can be used to easily build/modify HTML tags programmatically. + * + * Autolinker uses this abstraction to create HTML tags, and then write them out as strings. You may also use + * this class in your code, especially within a {@link Autolinker#replaceFn replaceFn}. + * + * ## Examples + * + * Example instantiation: + * + * var tag = new Autolinker.HtmlTag( { + * tagName : 'a', + * attrs : { 'href': 'http://google.com', 'class': 'external-link' }, + * innerHtml : 'Google' + * } ); + * + * tag.toAnchorString(); // Google + * + * // Individual accessor methods + * tag.getTagName(); // 'a' + * tag.getAttr( 'href' ); // 'http://google.com' + * tag.hasClass( 'external-link' ); // true + * + * + * Using mutator methods (which may be used in combination with instantiation config properties): + * + * var tag = new Autolinker.HtmlTag(); + * tag.setTagName( 'a' ); + * tag.setAttr( 'href', 'http://google.com' ); + * tag.addClass( 'external-link' ); + * tag.setInnerHtml( 'Google' ); + * + * tag.getTagName(); // 'a' + * tag.getAttr( 'href' ); // 'http://google.com' + * tag.hasClass( 'external-link' ); // true + * + * tag.toAnchorString(); // Google + * + * + * ## Example use within a {@link Autolinker#replaceFn replaceFn} + * + * var html = Autolinker.link( "Test google.com", { + * replaceFn : function( autolinker, match ) { + * var tag = autolinker.getTagBuilder().build( match ); // returns an {@link Autolinker.HtmlTag} instance, configured with the Match's href and anchor text + * tag.setAttr( 'rel', 'nofollow' ); + * + * return tag; + * } + * } ); + * + * // generated html: + * // Test google.com + * + * + * ## Example use with a new tag for the replacement + * + * var html = Autolinker.link( "Test google.com", { + * replaceFn : function( autolinker, match ) { + * var tag = new Autolinker.HtmlTag( { + * tagName : 'button', + * attrs : { 'title': 'Load URL: ' + match.getAnchorHref() }, + * innerHtml : 'Load URL: ' + match.getAnchorText() + * } ); + * + * return tag; + * } + * } ); + * + * // generated html: + * // Test + */ +Autolinker.HtmlTag = Autolinker.Util.extend( Object, { + + /** + * @cfg {String} tagName + * + * The tag name. Ex: 'a', 'button', etc. + * + * Not required at instantiation time, but should be set using {@link #setTagName} before {@link #toAnchorString} + * is executed. + */ + + /** + * @cfg {Object.} attrs + * + * An key/value Object (map) of attributes to create the tag with. The keys are the attribute names, and the + * values are the attribute values. + */ + + /** + * @cfg {String} innerHtml + * + * The inner HTML for the tag. + * + * Note the camel case name on `innerHtml`. Acronyms are camelCased in this utility (such as not to run into the acronym + * naming inconsistency that the DOM developers created with `XMLHttpRequest`). You may alternatively use {@link #innerHTML} + * if you prefer, but this one is recommended. + */ + + /** + * @cfg {String} innerHTML + * + * Alias of {@link #innerHtml}, accepted for consistency with the browser DOM api, but prefer the camelCased version + * for acronym names. + */ + + + /** + * @protected + * @property {RegExp} whitespaceRegex + * + * Regular expression used to match whitespace in a string of CSS classes. + */ + whitespaceRegex : /\s+/, + + + /** + * @constructor + * @param {Object} [cfg] The configuration properties for this class, in an Object (map) + */ + constructor : function( cfg ) { + Autolinker.Util.assign( this, cfg ); + + this.innerHtml = this.innerHtml || this.innerHTML; // accept either the camelCased form or the fully capitalized acronym + }, + + + /** + * Sets the tag name that will be used to generate the tag with. + * + * @param {String} tagName + * @return {Autolinker.HtmlTag} This HtmlTag instance, so that method calls may be chained. + */ + setTagName : function( tagName ) { + this.tagName = tagName; + return this; + }, + + + /** + * Retrieves the tag name. + * + * @return {String} + */ + getTagName : function() { + return this.tagName || ""; + }, + + + /** + * Sets an attribute on the HtmlTag. + * + * @param {String} attrName The attribute name to set. + * @param {String} attrValue The attribute value to set. + * @return {Autolinker.HtmlTag} This HtmlTag instance, so that method calls may be chained. + */ + setAttr : function( attrName, attrValue ) { + var tagAttrs = this.getAttrs(); + tagAttrs[ attrName ] = attrValue; + + return this; + }, + + + /** + * Retrieves an attribute from the HtmlTag. If the attribute does not exist, returns `undefined`. + * + * @param {String} name The attribute name to retrieve. + * @return {String} The attribute's value, or `undefined` if it does not exist on the HtmlTag. + */ + getAttr : function( attrName ) { + return this.getAttrs()[ attrName ]; + }, + + + /** + * Sets one or more attributes on the HtmlTag. + * + * @param {Object.} attrs A key/value Object (map) of the attributes to set. + * @return {Autolinker.HtmlTag} This HtmlTag instance, so that method calls may be chained. + */ + setAttrs : function( attrs ) { + var tagAttrs = this.getAttrs(); + Autolinker.Util.assign( tagAttrs, attrs ); + + return this; + }, + + + /** + * Retrieves the attributes Object (map) for the HtmlTag. + * + * @return {Object.} A key/value object of the attributes for the HtmlTag. + */ + getAttrs : function() { + return this.attrs || ( this.attrs = {} ); + }, + + + /** + * Sets the provided `cssClass`, overwriting any current CSS classes on the HtmlTag. + * + * @param {String} cssClass One or more space-separated CSS classes to set (overwrite). + * @return {Autolinker.HtmlTag} This HtmlTag instance, so that method calls may be chained. + */ + setClass : function( cssClass ) { + return this.setAttr( 'class', cssClass ); + }, + + + /** + * Convenience method to add one or more CSS classes to the HtmlTag. Will not add duplicate CSS classes. + * + * @param {String} cssClass One or more space-separated CSS classes to add. + * @return {Autolinker.HtmlTag} This HtmlTag instance, so that method calls may be chained. + */ + addClass : function( cssClass ) { + var classAttr = this.getClass(), + whitespaceRegex = this.whitespaceRegex, + indexOf = Autolinker.Util.indexOf, // to support IE8 and below + classes = ( !classAttr ) ? [] : classAttr.split( whitespaceRegex ), + newClasses = cssClass.split( whitespaceRegex ), + newClass; + + while( newClass = newClasses.shift() ) { + if( indexOf( classes, newClass ) === -1 ) { + classes.push( newClass ); + } + } + + this.getAttrs()[ 'class' ] = classes.join( " " ); + return this; + }, + + + /** + * Convenience method to remove one or more CSS classes from the HtmlTag. + * + * @param {String} cssClass One or more space-separated CSS classes to remove. + * @return {Autolinker.HtmlTag} This HtmlTag instance, so that method calls may be chained. + */ + removeClass : function( cssClass ) { + var classAttr = this.getClass(), + whitespaceRegex = this.whitespaceRegex, + indexOf = Autolinker.Util.indexOf, // to support IE8 and below + classes = ( !classAttr ) ? [] : classAttr.split( whitespaceRegex ), + removeClasses = cssClass.split( whitespaceRegex ), + removeClass; + + while( classes.length && ( removeClass = removeClasses.shift() ) ) { + var idx = indexOf( classes, removeClass ); + if( idx !== -1 ) { + classes.splice( idx, 1 ); + } + } + + this.getAttrs()[ 'class' ] = classes.join( " " ); + return this; + }, + + + /** + * Convenience method to retrieve the CSS class(es) for the HtmlTag, which will each be separated by spaces when + * there are multiple. + * + * @return {String} + */ + getClass : function() { + return this.getAttrs()[ 'class' ] || ""; + }, + + + /** + * Convenience method to check if the tag has a CSS class or not. + * + * @param {String} cssClass The CSS class to check for. + * @return {Boolean} `true` if the HtmlTag has the CSS class, `false` otherwise. + */ + hasClass : function( cssClass ) { + return ( ' ' + this.getClass() + ' ' ).indexOf( ' ' + cssClass + ' ' ) !== -1; + }, + + + /** + * Sets the inner HTML for the tag. + * + * @param {String} html The inner HTML to set. + * @return {Autolinker.HtmlTag} This HtmlTag instance, so that method calls may be chained. + */ + setInnerHtml : function( html ) { + this.innerHtml = html; + + return this; + }, + + + /** + * Retrieves the inner HTML for the tag. + * + * @return {String} + */ + getInnerHtml : function() { + return this.innerHtml || ""; + }, + + + /** + * Override of superclass method used to generate the HTML string for the tag. + * + * @return {String} + */ + toAnchorString : function() { + var tagName = this.getTagName(), + attrsStr = this.buildAttrsStr(); + + attrsStr = ( attrsStr ) ? ' ' + attrsStr : ''; // prepend a space if there are actually attributes + + return [ '<', tagName, attrsStr, '>', this.getInnerHtml(), '' ].join( "" ); + }, + + + /** + * Support method for {@link #toAnchorString}, returns the string space-separated key="value" pairs, used to populate + * the stringified HtmlTag. + * + * @protected + * @return {String} Example return: `attr1="value1" attr2="value2"` + */ + buildAttrsStr : function() { + if( !this.attrs ) return ""; // no `attrs` Object (map) has been set, return empty string + + var attrs = this.getAttrs(), + attrsArr = []; + + for( var prop in attrs ) { + if( attrs.hasOwnProperty( prop ) ) { + attrsArr.push( prop + '="' + attrs[ prop ] + '"' ); + } + } + return attrsArr.join( " " ); + } + +} ); + +/*global Autolinker */ +/*jshint sub:true */ +/** + * @protected + * @class Autolinker.AnchorTagBuilder + * @extends Object + * + * Builds anchor (<a>) tags for the Autolinker utility when a match is found. + * + * Normally this class is instantiated, configured, and used internally by an {@link Autolinker} instance, but may + * actually be retrieved in a {@link Autolinker#replaceFn replaceFn} to create {@link Autolinker.HtmlTag HtmlTag} instances + * which may be modified before returning from the {@link Autolinker#replaceFn replaceFn}. For example: + * + * var html = Autolinker.link( "Test google.com", { + * replaceFn : function( autolinker, match ) { + * var tag = autolinker.getTagBuilder().build( match ); // returns an {@link Autolinker.HtmlTag} instance + * tag.setAttr( 'rel', 'nofollow' ); + * + * return tag; + * } + * } ); + * + * // generated html: + * // Test google.com + */ +Autolinker.AnchorTagBuilder = Autolinker.Util.extend( Object, { + + /** + * @cfg {Boolean} newWindow + * @inheritdoc Autolinker#newWindow + */ + + /** + * @cfg {Number} truncate + * @inheritdoc Autolinker#truncate + */ + + /** + * @cfg {String} className + * @inheritdoc Autolinker#className + */ + + + /** + * @constructor + * @param {Object} [cfg] The configuration options for the AnchorTagBuilder instance, specified in an Object (map). + */ + constructor : function( cfg ) { + Autolinker.Util.assign( this, cfg ); + }, + + + /** + * Generates the actual anchor (<a>) tag to use in place of the + * matched text, via its `match` object. + * + * @param {Autolinker.match.Match} match The Match instance to generate an + * anchor tag from. + * @return {Autolinker.HtmlTag} The HtmlTag instance for the anchor tag. + */ + build : function( match ) { + var tag = new Autolinker.HtmlTag( { + tagName : 'a', + attrs : this.createAttrs( match.getType(), match.getAnchorHref() ), + innerHtml : this.processAnchorText( match.getAnchorText() ) + } ); + + return tag; + }, + + + /** + * Creates the Object (map) of the HTML attributes for the anchor (<a>) + * tag being generated. + * + * @protected + * @param {"url"/"email"/"phone"/"twitter"/"hashtag"} matchType The type of + * match that an anchor tag is being generated for. + * @param {String} href The href for the anchor tag. + * @return {Object} A key/value Object (map) of the anchor tag's attributes. + */ + createAttrs : function( matchType, anchorHref ) { + var attrs = { + 'href' : anchorHref // we'll always have the `href` attribute + }; + + var cssClass = this.createCssClass( matchType ); + if( cssClass ) { + attrs[ 'class' ] = cssClass; + } + if( this.newWindow ) { + attrs[ 'target' ] = "_blank"; + } + + return attrs; + }, + + + /** + * Creates the CSS class that will be used for a given anchor tag, based on + * the `matchType` and the {@link #className} config. + * + * @private + * @param {"url"/"email"/"phone"/"twitter"/"hashtag"} matchType The type of + * match that an anchor tag is being generated for. + * @return {String} The CSS class string for the link. Example return: + * "myLink myLink-url". If no {@link #className} was configured, returns + * an empty string. + */ + createCssClass : function( matchType ) { + var className = this.className; + + if( !className ) + return ""; + else + return className + " " + className + "-" + matchType; // ex: "myLink myLink-url", "myLink myLink-email", "myLink myLink-phone", "myLink myLink-twitter", or "myLink myLink-hashtag" + }, + + + /** + * Processes the `anchorText` by truncating the text according to the + * {@link #truncate} config. + * + * @private + * @param {String} anchorText The anchor tag's text (i.e. what will be + * displayed). + * @return {String} The processed `anchorText`. + */ + processAnchorText : function( anchorText ) { + anchorText = this.doTruncate( anchorText ); + + return anchorText; + }, + + + /** + * Performs the truncation of the `anchorText`, if the `anchorText` is + * longer than the {@link #truncate} option. Truncates the text to 2 + * characters fewer than the {@link #truncate} option, and adds ".." to the + * end. + * + * @private + * @param {String} text The anchor tag's text (i.e. what will be displayed). + * @return {String} The truncated anchor text. + */ + doTruncate : function( anchorText ) { + return Autolinker.Util.ellipsis( anchorText, this.truncate || Number.POSITIVE_INFINITY ); + } + +} ); +/*global Autolinker */ +/** + * @private + * @class Autolinker.htmlParser.HtmlParser + * @extends Object + * + * An HTML parser implementation which simply walks an HTML string and returns an array of + * {@link Autolinker.htmlParser.HtmlNode HtmlNodes} that represent the basic HTML structure of the input string. + * + * Autolinker uses this to only link URLs/emails/Twitter handles within text nodes, effectively ignoring / "walking + * around" HTML tags. + */ +Autolinker.htmlParser.HtmlParser = Autolinker.Util.extend( Object, { + + /** + * @private + * @property {RegExp} htmlRegex + * + * The regular expression used to pull out HTML tags from a string. Handles namespaced HTML tags and + * attribute names, as specified by http://www.w3.org/TR/html-markup/syntax.html. + * + * Capturing groups: + * + * 1. The "!DOCTYPE" tag name, if a tag is a <!DOCTYPE> tag. + * 2. If it is an end tag, this group will have the '/'. + * 3. If it is a comment tag, this group will hold the comment text (i.e. + * the text inside the `<!--` and `-->`. + * 4. The tag name for all tags (other than the <!DOCTYPE> tag) + */ + htmlRegex : (function() { + var commentTagRegex = /!--([\s\S]+?)--/, + tagNameRegex = /[0-9a-zA-Z][0-9a-zA-Z:]*/, + attrNameRegex = /[^\s\0"'>\/=\x01-\x1F\x7F]+/, // the unicode range accounts for excluding control chars, and the delete char + attrValueRegex = /(?:"[^"]*?"|'[^']*?'|[^'"=<>`\s]+)/, // double quoted, single quoted, or unquoted attribute values + nameEqualsValueRegex = attrNameRegex.source + '(?:\\s*=\\s*' + attrValueRegex.source + ')?'; // optional '=[value]' + + return new RegExp( [ + // for tag. Ex: ) + '(?:', + '<(!DOCTYPE)', // *** Capturing Group 1 - If it's a doctype tag + + // Zero or more attributes following the tag name + '(?:', + '\\s+', // one or more whitespace chars before an attribute + + // Either: + // A. attr="value", or + // B. "value" alone (To cover example doctype tag: ) + '(?:', nameEqualsValueRegex, '|', attrValueRegex.source + ')', + ')*', + '>', + ')', + + '|', + + // All other HTML tags (i.e. tags that are not ) + '(?:', + '<(/)?', // Beginning of a tag or comment. Either '<' for a start tag, or '' + + ')', + ')', + '>', + ')' + ].join( "" ), 'gi' ); + } )(), + + /** + * @private + * @property {RegExp} htmlCharacterEntitiesRegex + * + * The regular expression that matches common HTML character entities. + * + * Ignoring & as it could be part of a query string -- handling it separately. + */ + htmlCharacterEntitiesRegex: /( | |<|<|>|>|"|"|')/gi, + + + /** + * Parses an HTML string and returns a simple array of {@link Autolinker.htmlParser.HtmlNode HtmlNodes} + * to represent the HTML structure of the input string. + * + * @param {String} html The HTML to parse. + * @return {Autolinker.htmlParser.HtmlNode[]} + */ + parse : function( html ) { + var htmlRegex = this.htmlRegex, + currentResult, + lastIndex = 0, + textAndEntityNodes, + nodes = []; // will be the result of the method + + while( ( currentResult = htmlRegex.exec( html ) ) !== null ) { + var tagText = currentResult[ 0 ], + commentText = currentResult[ 3 ], // if we've matched a comment + tagName = currentResult[ 1 ] || currentResult[ 4 ], // The tag (ex: "!DOCTYPE"), or another tag (ex: "a" or "img") + isClosingTag = !!currentResult[ 2 ], + inBetweenTagsText = html.substring( lastIndex, currentResult.index ); + + // Push TextNodes and EntityNodes for any text found between tags + if( inBetweenTagsText ) { + textAndEntityNodes = this.parseTextAndEntityNodes( inBetweenTagsText ); + nodes.push.apply( nodes, textAndEntityNodes ); + } + + // Push the CommentNode or ElementNode + if( commentText ) { + nodes.push( this.createCommentNode( tagText, commentText ) ); + } else { + nodes.push( this.createElementNode( tagText, tagName, isClosingTag ) ); + } + + lastIndex = currentResult.index + tagText.length; + } + + // Process any remaining text after the last HTML element. Will process all of the text if there were no HTML elements. + if( lastIndex < html.length ) { + var text = html.substring( lastIndex ); + + // Push TextNodes and EntityNodes for any text found between tags + if( text ) { + textAndEntityNodes = this.parseTextAndEntityNodes( text ); + nodes.push.apply( nodes, textAndEntityNodes ); + } + } + + return nodes; + }, + + + /** + * Parses text and HTML entity nodes from a given string. The input string + * should not have any HTML tags (elements) within it. + * + * @private + * @param {String} text The text to parse. + * @return {Autolinker.htmlParser.HtmlNode[]} An array of HtmlNodes to + * represent the {@link Autolinker.htmlParser.TextNode TextNodes} and + * {@link Autolinker.htmlParser.EntityNode EntityNodes} found. + */ + parseTextAndEntityNodes : function( text ) { + var nodes = [], + textAndEntityTokens = Autolinker.Util.splitAndCapture( text, this.htmlCharacterEntitiesRegex ); // split at HTML entities, but include the HTML entities in the results array + + // Every even numbered token is a TextNode, and every odd numbered token is an EntityNode + // For example: an input `text` of "Test "this" today" would turn into the + // `textAndEntityTokens`: [ 'Test ', '"', 'this', '"', ' today' ] + for( var i = 0, len = textAndEntityTokens.length; i < len; i += 2 ) { + var textToken = textAndEntityTokens[ i ], + entityToken = textAndEntityTokens[ i + 1 ]; + + if( textToken ) nodes.push( this.createTextNode( textToken ) ); + if( entityToken ) nodes.push( this.createEntityNode( entityToken ) ); + } + return nodes; + }, + + + /** + * Factory method to create an {@link Autolinker.htmlParser.CommentNode CommentNode}. + * + * @private + * @param {String} tagText The full text of the tag (comment) that was + * matched, including its <!-- and -->. + * @param {String} comment The full text of the comment that was matched. + */ + createCommentNode : function( tagText, commentText ) { + return new Autolinker.htmlParser.CommentNode( { + text: tagText, + comment: Autolinker.Util.trim( commentText ) + } ); + }, + + + /** + * Factory method to create an {@link Autolinker.htmlParser.ElementNode ElementNode}. + * + * @private + * @param {String} tagText The full text of the tag (element) that was + * matched, including its attributes. + * @param {String} tagName The name of the tag. Ex: An <img> tag would + * be passed to this method as "img". + * @param {Boolean} isClosingTag `true` if it's a closing tag, false + * otherwise. + * @return {Autolinker.htmlParser.ElementNode} + */ + createElementNode : function( tagText, tagName, isClosingTag ) { + return new Autolinker.htmlParser.ElementNode( { + text : tagText, + tagName : tagName.toLowerCase(), + closing : isClosingTag + } ); + }, + + + /** + * Factory method to create a {@link Autolinker.htmlParser.EntityNode EntityNode}. + * + * @private + * @param {String} text The text that was matched for the HTML entity (such + * as '&nbsp;'). + * @return {Autolinker.htmlParser.EntityNode} + */ + createEntityNode : function( text ) { + return new Autolinker.htmlParser.EntityNode( { text: text } ); + }, + + + /** + * Factory method to create a {@link Autolinker.htmlParser.TextNode TextNode}. + * + * @private + * @param {String} text The text that was matched. + * @return {Autolinker.htmlParser.TextNode} + */ + createTextNode : function( text ) { + return new Autolinker.htmlParser.TextNode( { text: text } ); + } + +} ); +/*global Autolinker */ +/** + * @abstract + * @class Autolinker.htmlParser.HtmlNode + * + * Represents an HTML node found in an input string. An HTML node is one of the following: + * + * 1. An {@link Autolinker.htmlParser.ElementNode ElementNode}, which represents HTML tags. + * 2. A {@link Autolinker.htmlParser.TextNode TextNode}, which represents text outside or within HTML tags. + * 3. A {@link Autolinker.htmlParser.EntityNode EntityNode}, which represents one of the known HTML + * entities that Autolinker looks for. This includes common ones such as &quot; and &nbsp; + */ +Autolinker.htmlParser.HtmlNode = Autolinker.Util.extend( Object, { + + /** + * @cfg {String} text (required) + * + * The original text that was matched for the HtmlNode. + * + * - In the case of an {@link Autolinker.htmlParser.ElementNode ElementNode}, this will be the tag's + * text. + * - In the case of a {@link Autolinker.htmlParser.TextNode TextNode}, this will be the text itself. + * - In the case of a {@link Autolinker.htmlParser.EntityNode EntityNode}, this will be the text of + * the HTML entity. + */ + text : "", + + + /** + * @constructor + * @param {Object} cfg The configuration properties for the Match instance, specified in an Object (map). + */ + constructor : function( cfg ) { + Autolinker.Util.assign( this, cfg ); + }, + + + /** + * Returns a string name for the type of node that this class represents. + * + * @abstract + * @return {String} + */ + getType : Autolinker.Util.abstractMethod, + + + /** + * Retrieves the {@link #text} for the HtmlNode. + * + * @return {String} + */ + getText : function() { + return this.text; + } + +} ); +/*global Autolinker */ +/** + * @class Autolinker.htmlParser.CommentNode + * @extends Autolinker.htmlParser.HtmlNode + * + * Represents an HTML comment node that has been parsed by the + * {@link Autolinker.htmlParser.HtmlParser}. + * + * See this class's superclass ({@link Autolinker.htmlParser.HtmlNode}) for more + * details. + */ +Autolinker.htmlParser.CommentNode = Autolinker.Util.extend( Autolinker.htmlParser.HtmlNode, { + + /** + * @cfg {String} comment (required) + * + * The text inside the comment tag. This text is stripped of any leading or + * trailing whitespace. + */ + comment : '', + + + /** + * Returns a string name for the type of node that this class represents. + * + * @return {String} + */ + getType : function() { + return 'comment'; + }, + + + /** + * Returns the comment inside the comment tag. + * + * @return {String} + */ + getComment : function() { + return this.comment; + } + +} ); +/*global Autolinker */ +/** + * @class Autolinker.htmlParser.ElementNode + * @extends Autolinker.htmlParser.HtmlNode + * + * Represents an HTML element node that has been parsed by the {@link Autolinker.htmlParser.HtmlParser}. + * + * See this class's superclass ({@link Autolinker.htmlParser.HtmlNode}) for more details. + */ +Autolinker.htmlParser.ElementNode = Autolinker.Util.extend( Autolinker.htmlParser.HtmlNode, { + + /** + * @cfg {String} tagName (required) + * + * The name of the tag that was matched. + */ + tagName : '', + + /** + * @cfg {Boolean} closing (required) + * + * `true` if the element (tag) is a closing tag, `false` if its an opening tag. + */ + closing : false, + + + /** + * Returns a string name for the type of node that this class represents. + * + * @return {String} + */ + getType : function() { + return 'element'; + }, + + + /** + * Returns the HTML element's (tag's) name. Ex: for an <img> tag, returns "img". + * + * @return {String} + */ + getTagName : function() { + return this.tagName; + }, + + + /** + * Determines if the HTML element (tag) is a closing tag. Ex: <div> returns + * `false`, while </div> returns `true`. + * + * @return {Boolean} + */ + isClosing : function() { + return this.closing; + } + +} ); +/*global Autolinker */ +/** + * @class Autolinker.htmlParser.EntityNode + * @extends Autolinker.htmlParser.HtmlNode + * + * Represents a known HTML entity node that has been parsed by the {@link Autolinker.htmlParser.HtmlParser}. + * Ex: '&nbsp;', or '&#160;' (which will be retrievable from the {@link #getText} method. + * + * Note that this class will only be returned from the HtmlParser for the set of checked HTML entity nodes + * defined by the {@link Autolinker.htmlParser.HtmlParser#htmlCharacterEntitiesRegex}. + * + * See this class's superclass ({@link Autolinker.htmlParser.HtmlNode}) for more details. + */ +Autolinker.htmlParser.EntityNode = Autolinker.Util.extend( Autolinker.htmlParser.HtmlNode, { + + /** + * Returns a string name for the type of node that this class represents. + * + * @return {String} + */ + getType : function() { + return 'entity'; + } + +} ); +/*global Autolinker */ +/** + * @class Autolinker.htmlParser.TextNode + * @extends Autolinker.htmlParser.HtmlNode + * + * Represents a text node that has been parsed by the {@link Autolinker.htmlParser.HtmlParser}. + * + * See this class's superclass ({@link Autolinker.htmlParser.HtmlNode}) for more details. + */ +Autolinker.htmlParser.TextNode = Autolinker.Util.extend( Autolinker.htmlParser.HtmlNode, { + + /** + * Returns a string name for the type of node that this class represents. + * + * @return {String} + */ + getType : function() { + return 'text'; + } + +} ); +/*global Autolinker */ +/** + * @private + * @class Autolinker.matchParser.MatchParser + * @extends Object + * + * Used by Autolinker to parse potential matches, given an input string of text. + * + * The MatchParser is fed a non-HTML string in order to search for matches. + * Autolinker first uses the {@link Autolinker.htmlParser.HtmlParser} to "walk + * around" HTML tags, and then the text around the HTML tags is passed into the + * MatchParser in order to find the actual matches. + */ +Autolinker.matchParser.MatchParser = Autolinker.Util.extend( Object, { + + /** + * @cfg {Boolean} urls + * @inheritdoc Autolinker#urls + */ + urls : true, + + /** + * @cfg {Boolean} email + * @inheritdoc Autolinker#email + */ + email : true, + + /** + * @cfg {Boolean} twitter + * @inheritdoc Autolinker#twitter + */ + twitter : true, + + /** + * @cfg {Boolean} phone + * @inheritdoc Autolinker#phone + */ + phone: true, + + /** + * @cfg {Boolean/String} hashtag + * @inheritdoc Autolinker#hashtag + */ + hashtag : false, + + /** + * @cfg {Boolean} stripPrefix + * @inheritdoc Autolinker#stripPrefix + */ + stripPrefix : true, + + + /** + * @private + * @property {RegExp} matcherRegex + * + * The regular expression that matches URLs, email addresses, phone #s, + * Twitter handles, and Hashtags. + * + * This regular expression has the following capturing groups: + * + * 1. Group that is used to determine if there is a Twitter handle match + * (i.e. \@someTwitterUser). Simply check for its existence to determine + * if there is a Twitter handle match. The next couple of capturing + * groups give information about the Twitter handle match. + * 2. The whitespace character before the \@sign in a Twitter handle. This + * is needed because there are no lookbehinds in JS regular expressions, + * and can be used to reconstruct the original string in a replace(). + * 3. The Twitter handle itself in a Twitter match. If the match is + * '@someTwitterUser', the handle is 'someTwitterUser'. + * 4. Group that matches an email address. Used to determine if the match + * is an email address, as well as holding the full address. Ex: + * 'me@my.com' + * 5. Group that matches a URL in the input text. Ex: 'http://google.com', + * 'www.google.com', or just 'google.com'. This also includes a path, + * url parameters, or hash anchors. Ex: google.com/path/to/file?q1=1&q2=2#myAnchor + * 6. Group that matches a protocol URL (i.e. 'http://google.com'). This is + * used to match protocol URLs with just a single word, like 'http://localhost', + * where we won't double check that the domain name has at least one '.' + * in it. + * 7. A protocol-relative ('//') match for the case of a 'www.' prefixed + * URL. Will be an empty string if it is not a protocol-relative match. + * We need to know the character before the '//' in order to determine + * if it is a valid match or the // was in a string we don't want to + * auto-link. + * 8. A protocol-relative ('//') match for the case of a known TLD prefixed + * URL. Will be an empty string if it is not a protocol-relative match. + * See #6 for more info. + * 9. Group that is used to determine if there is a phone number match. The + * next 3 groups give segments of the phone number. + * 10. Group that is used to determine if there is a Hashtag match + * (i.e. \#someHashtag). Simply check for its existence to determine if + * there is a Hashtag match. The next couple of capturing groups give + * information about the Hashtag match. + * 11. The whitespace character before the #sign in a Hashtag handle. This + * is needed because there are no look-behinds in JS regular + * expressions, and can be used to reconstruct the original string in a + * replace(). + * 12. The Hashtag itself in a Hashtag match. If the match is + * '#someHashtag', the hashtag is 'someHashtag'. + */ + matcherRegex : (function() { + var twitterRegex = /(^|[^\w])@(\w{1,15})/, // For matching a twitter handle. Ex: @gregory_jacobs + + hashtagRegex = /(^|[^\w])#(\w{1,15})/, // For matching a Hashtag. Ex: #games + + emailRegex = /(?:[\-;:&=\+\$,\w\.]+@)/, // something@ for email addresses (a.k.a. local-part) + phoneRegex = /(?:\+?\d{1,3}[-\s.])?\(?\d{3}\)?[-\s.]?\d{3}[-\s.]\d{4}/, // ex: (123) 456-7890, 123 456 7890, 123-456-7890, etc. + protocolRegex = /(?:[A-Za-z][-.+A-Za-z0-9]+:(?![A-Za-z][-.+A-Za-z0-9]+:\/\/)(?!\d+\/?)(?:\/\/)?)/, // match protocol, allow in format "http://" or "mailto:". However, do not match the first part of something like 'link:http://www.google.com' (i.e. don't match "link:"). Also, make sure we don't interpret 'google.com:8000' as if 'google.com' was a protocol here (i.e. ignore a trailing port number in this regex) + wwwRegex = /(?:www\.)/, // starting with 'www.' + domainNameRegex = /[A-Za-z0-9\.\-]*[A-Za-z0-9\-]/, // anything looking at all like a domain, non-unicode domains, not ending in a period + tldRegex = /\.(?:international|construction|contractors|enterprises|photography|productions|foundation|immobilien|industries|management|properties|technology|christmas|community|directory|education|equipment|institute|marketing|solutions|vacations|bargains|boutique|builders|catering|cleaning|clothing|computer|democrat|diamonds|graphics|holdings|lighting|partners|plumbing|supplies|training|ventures|academy|careers|company|cruises|domains|exposed|flights|florist|gallery|guitars|holiday|kitchen|neustar|okinawa|recipes|rentals|reviews|shiksha|singles|support|systems|agency|berlin|camera|center|coffee|condos|dating|estate|events|expert|futbol|kaufen|luxury|maison|monash|museum|nagoya|photos|repair|report|social|supply|tattoo|tienda|travel|viajes|villas|vision|voting|voyage|actor|build|cards|cheap|codes|dance|email|glass|house|mango|ninja|parts|photo|shoes|solar|today|tokyo|tools|watch|works|aero|arpa|asia|best|bike|blue|buzz|camp|club|cool|coop|farm|fish|gift|guru|info|jobs|kiwi|kred|land|limo|link|menu|mobi|moda|name|pics|pink|post|qpon|rich|ruhr|sexy|tips|vote|voto|wang|wien|wiki|zone|bar|bid|biz|cab|cat|ceo|com|edu|gov|int|kim|mil|net|onl|org|pro|pub|red|tel|uno|wed|xxx|xyz|ac|ad|ae|af|ag|ai|al|am|an|ao|aq|ar|as|at|au|aw|ax|az|ba|bb|bd|be|bf|bg|bh|bi|bj|bm|bn|bo|br|bs|bt|bv|bw|by|bz|ca|cc|cd|cf|cg|ch|ci|ck|cl|cm|cn|co|cr|cu|cv|cw|cx|cy|cz|de|dj|dk|dm|do|dz|ec|ee|eg|er|es|et|eu|fi|fj|fk|fm|fo|fr|ga|gb|gd|ge|gf|gg|gh|gi|gl|gm|gn|gp|gq|gr|gs|gt|gu|gw|gy|hk|hm|hn|hr|ht|hu|id|ie|il|im|in|io|iq|ir|is|it|je|jm|jo|jp|ke|kg|kh|ki|km|kn|kp|kr|kw|ky|kz|la|lb|lc|li|lk|lr|ls|lt|lu|lv|ly|ma|mc|md|me|mg|mh|mk|ml|mm|mn|mo|mp|mq|mr|ms|mt|mu|mv|mw|mx|my|mz|na|nc|ne|nf|ng|ni|nl|no|np|nr|nu|nz|om|pa|pe|pf|pg|ph|pk|pl|pm|pn|pr|ps|pt|pw|py|qa|re|ro|rs|ru|rw|sa|sb|sc|sd|se|sg|sh|si|sj|sk|sl|sm|sn|so|sr|st|su|sv|sx|sy|sz|tc|td|tf|tg|th|tj|tk|tl|tm|tn|to|tp|tr|tt|tv|tw|tz|ua|ug|uk|us|uy|uz|va|vc|ve|vg|vi|vn|vu|wf|ws|ye|yt|za|zm|zw)\b/, // match our known top level domains (TLDs) + + // Allow optional path, query string, and hash anchor, not ending in the following characters: "?!:,.;" + // http://blog.codinghorror.com/the-problem-with-urls/ + urlSuffixRegex = /[\-A-Za-z0-9+&@#\/%=~_()|'$*\[\]?!:,.;]*[\-A-Za-z0-9+&@#\/%=~_()|'$*\[\]]/; + + return new RegExp( [ + '(', // *** Capturing group $1, which can be used to check for a twitter handle match. Use group $3 for the actual twitter handle though. $2 may be used to reconstruct the original string in a replace() + // *** Capturing group $2, which matches the whitespace character before the '@' sign (needed because of no lookbehinds), and + // *** Capturing group $3, which matches the actual twitter handle + twitterRegex.source, + ')', + + '|', + + '(', // *** Capturing group $4, which is used to determine an email match + emailRegex.source, + domainNameRegex.source, + tldRegex.source, + ')', + + '|', + + '(', // *** Capturing group $5, which is used to match a URL + '(?:', // parens to cover match for protocol (optional), and domain + '(', // *** Capturing group $6, for a protocol-prefixed url (ex: http://google.com) + protocolRegex.source, + domainNameRegex.source, + ')', + + '|', + + '(?:', // non-capturing paren for a 'www.' prefixed url (ex: www.google.com) + '(.?//)?', // *** Capturing group $7 for an optional protocol-relative URL. Must be at the beginning of the string or start with a non-word character + wwwRegex.source, + domainNameRegex.source, + ')', + + '|', + + '(?:', // non-capturing paren for known a TLD url (ex: google.com) + '(.?//)?', // *** Capturing group $8 for an optional protocol-relative URL. Must be at the beginning of the string or start with a non-word character + domainNameRegex.source, + tldRegex.source, + ')', + ')', + + '(?:' + urlSuffixRegex.source + ')?', // match for path, query string, and/or hash anchor - optional + ')', + + '|', + + // this setup does not scale well for open extension :( Need to rethink design of autolinker... + // *** Capturing group $9, which matches a (USA for now) phone number + '(', + phoneRegex.source, + ')', + + '|', + + '(', // *** Capturing group $10, which can be used to check for a Hashtag match. Use group $12 for the actual Hashtag though. $11 may be used to reconstruct the original string in a replace() + // *** Capturing group $11, which matches the whitespace character before the '#' sign (needed because of no lookbehinds), and + // *** Capturing group $12, which matches the actual Hashtag + hashtagRegex.source, + ')' + ].join( "" ), 'gi' ); + } )(), + + /** + * @private + * @property {RegExp} charBeforeProtocolRelMatchRegex + * + * The regular expression used to retrieve the character before a + * protocol-relative URL match. + * + * This is used in conjunction with the {@link #matcherRegex}, which needs + * to grab the character before a protocol-relative '//' due to the lack of + * a negative look-behind in JavaScript regular expressions. The character + * before the match is stripped from the URL. + */ + charBeforeProtocolRelMatchRegex : /^(.)?\/\//, + + /** + * @private + * @property {Autolinker.MatchValidator} matchValidator + * + * The MatchValidator object, used to filter out any false positives from + * the {@link #matcherRegex}. See {@link Autolinker.MatchValidator} for details. + */ + + + /** + * @constructor + * @param {Object} [cfg] The configuration options for the AnchorTagBuilder + * instance, specified in an Object (map). + */ + constructor : function( cfg ) { + Autolinker.Util.assign( this, cfg ); + + this.matchValidator = new Autolinker.MatchValidator(); + }, + + + /** + * Parses the input `text` to search for matches, and calls the `replaceFn` + * to allow replacements of the matches. Returns the `text` with matches + * replaced. + * + * @param {String} text The text to search and repace matches in. + * @param {Function} replaceFn The iterator function to handle the + * replacements. The function takes a single argument, a {@link Autolinker.match.Match} + * object, and should return the text that should make the replacement. + * @param {Object} [contextObj=window] The context object ("scope") to run + * the `replaceFn` in. + * @return {String} + */ + replace : function( text, replaceFn, contextObj ) { + var me = this; // for closure + + return text.replace( this.matcherRegex, function( matchStr, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) { + var matchDescObj = me.processCandidateMatch( matchStr, $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ); // "match description" object + + // Return out with no changes for match types that are disabled (url, + // email, phone, etc.), or for matches that are invalid (false + // positives from the matcherRegex, which can't use look-behinds + // since they are unavailable in JS). + if( !matchDescObj ) { + return matchStr; + + } else { + // Generate replacement text for the match from the `replaceFn` + var replaceStr = replaceFn.call( contextObj, matchDescObj.match ); + return matchDescObj.prefixStr + replaceStr + matchDescObj.suffixStr; + } + } ); + }, + + + /** + * Processes a candidate match from the {@link #matcherRegex}. + * + * Not all matches found by the regex are actual URL/Email/Phone/Twitter/Hashtag + * matches, as determined by the {@link #matchValidator}. In this case, the + * method returns `null`. Otherwise, a valid Object with `prefixStr`, + * `match`, and `suffixStr` is returned. + * + * @private + * @param {String} matchStr The full match that was found by the + * {@link #matcherRegex}. + * @param {String} twitterMatch The matched text of a Twitter handle, if the + * match is a Twitter match. + * @param {String} twitterHandlePrefixWhitespaceChar The whitespace char + * before the @ sign in a Twitter handle match. This is needed because of + * no lookbehinds in JS regexes, and is need to re-include the character + * for the anchor tag replacement. + * @param {String} twitterHandle The actual Twitter user (i.e the word after + * the @ sign in a Twitter match). + * @param {String} emailAddressMatch The matched email address for an email + * address match. + * @param {String} urlMatch The matched URL string for a URL match. + * @param {String} protocolUrlMatch The match URL string for a protocol + * match. Ex: 'http://yahoo.com'. This is used to match something like + * 'http://localhost', where we won't double check that the domain name + * has at least one '.' in it. + * @param {String} wwwProtocolRelativeMatch The '//' for a protocol-relative + * match from a 'www' url, with the character that comes before the '//'. + * @param {String} tldProtocolRelativeMatch The '//' for a protocol-relative + * match from a TLD (top level domain) match, with the character that + * comes before the '//'. + * @param {String} phoneMatch The matched text of a phone number + * @param {String} hashtagMatch The matched text of a Twitter + * Hashtag, if the match is a Hashtag match. + * @param {String} hashtagPrefixWhitespaceChar The whitespace char + * before the # sign in a Hashtag match. This is needed because of no + * lookbehinds in JS regexes, and is need to re-include the character for + * the anchor tag replacement. + * @param {String} hashtag The actual Hashtag (i.e the word + * after the # sign in a Hashtag match). + * + * @return {Object} A "match description object". This will be `null` if the + * match was invalid, or if a match type is disabled. Otherwise, this will + * be an Object (map) with the following properties: + * @return {String} return.prefixStr The char(s) that should be prepended to + * the replacement string. These are char(s) that were needed to be + * included from the regex match that were ignored by processing code, and + * should be re-inserted into the replacement stream. + * @return {String} return.suffixStr The char(s) that should be appended to + * the replacement string. These are char(s) that were needed to be + * included from the regex match that were ignored by processing code, and + * should be re-inserted into the replacement stream. + * @return {Autolinker.match.Match} return.match The Match object that + * represents the match that was found. + */ + processCandidateMatch : function( + matchStr, twitterMatch, twitterHandlePrefixWhitespaceChar, twitterHandle, + emailAddressMatch, urlMatch, protocolUrlMatch, wwwProtocolRelativeMatch, + tldProtocolRelativeMatch, phoneMatch, hashtagMatch, + hashtagPrefixWhitespaceChar, hashtag + ) { + // Note: The `matchStr` variable wil be fixed up to remove characters that are no longer needed (which will + // be added to `prefixStr` and `suffixStr`). + + var protocolRelativeMatch = wwwProtocolRelativeMatch || tldProtocolRelativeMatch, + match, // Will be an Autolinker.match.Match object + + prefixStr = "", // A string to use to prefix the anchor tag that is created. This is needed for the Twitter and Hashtag matches. + suffixStr = ""; // A string to suffix the anchor tag that is created. This is used if there is a trailing parenthesis that should not be auto-linked. + + // Return out with `null` for match types that are disabled (url, email, + // twitter, hashtag), or for matches that are invalid (false positives + // from the matcherRegex, which can't use look-behinds since they are + // unavailable in JS). + if( + ( urlMatch && !this.urls ) || + ( emailAddressMatch && !this.email ) || + ( phoneMatch && !this.phone ) || + ( twitterMatch && !this.twitter ) || + ( hashtagMatch && !this.hashtag ) || + !this.matchValidator.isValidMatch( urlMatch, protocolUrlMatch, protocolRelativeMatch ) + ) { + return null; + } + + // Handle a closing parenthesis at the end of the match, and exclude it + // if there is not a matching open parenthesis + // in the match itself. + if( this.matchHasUnbalancedClosingParen( matchStr ) ) { + matchStr = matchStr.substr( 0, matchStr.length - 1 ); // remove the trailing ")" + suffixStr = ")"; // this will be added after the generated tag + } + + if( emailAddressMatch ) { + match = new Autolinker.match.Email( { matchedText: matchStr, email: emailAddressMatch } ); + + } else if( twitterMatch ) { + // fix up the `matchStr` if there was a preceding whitespace char, + // which was needed to determine the match itself (since there are + // no look-behinds in JS regexes) + if( twitterHandlePrefixWhitespaceChar ) { + prefixStr = twitterHandlePrefixWhitespaceChar; + matchStr = matchStr.slice( 1 ); // remove the prefixed whitespace char from the match + } + match = new Autolinker.match.Twitter( { matchedText: matchStr, twitterHandle: twitterHandle } ); + + } else if( phoneMatch ) { + // remove non-numeric values from phone number string + var cleanNumber = matchStr.replace( /\D/g, '' ); + match = new Autolinker.match.Phone( { matchedText: matchStr, number: cleanNumber } ); + + } else if( hashtagMatch ) { + // fix up the `matchStr` if there was a preceding whitespace char, + // which was needed to determine the match itself (since there are + // no look-behinds in JS regexes) + if( hashtagPrefixWhitespaceChar ) { + prefixStr = hashtagPrefixWhitespaceChar; + matchStr = matchStr.slice( 1 ); // remove the prefixed whitespace char from the match + } + match = new Autolinker.match.Hashtag( { matchedText: matchStr, serviceName: this.hashtag, hashtag: hashtag } ); + + } else { // url match + // If it's a protocol-relative '//' match, remove the character + // before the '//' (which the matcherRegex needed to match due to + // the lack of a negative look-behind in JavaScript regular + // expressions) + if( protocolRelativeMatch ) { + var charBeforeMatch = protocolRelativeMatch.match( this.charBeforeProtocolRelMatchRegex )[ 1 ] || ""; + + if( charBeforeMatch ) { // fix up the `matchStr` if there was a preceding char before a protocol-relative match, which was needed to determine the match itself (since there are no look-behinds in JS regexes) + prefixStr = charBeforeMatch; + matchStr = matchStr.slice( 1 ); // remove the prefixed char from the match + } + } + + match = new Autolinker.match.Url( { + matchedText : matchStr, + url : matchStr, + protocolUrlMatch : !!protocolUrlMatch, + protocolRelativeMatch : !!protocolRelativeMatch, + stripPrefix : this.stripPrefix + } ); + } + + return { + prefixStr : prefixStr, + suffixStr : suffixStr, + match : match + }; + }, + + + /** + * Determines if a match found has an unmatched closing parenthesis. If so, + * this parenthesis will be removed from the match itself, and appended + * after the generated anchor tag in {@link #processCandidateMatch}. + * + * A match may have an extra closing parenthesis at the end of the match + * because the regular expression must include parenthesis for URLs such as + * "wikipedia.com/something_(disambiguation)", which should be auto-linked. + * + * However, an extra parenthesis *will* be included when the URL itself is + * wrapped in parenthesis, such as in the case of "(wikipedia.com/something_(disambiguation))". + * In this case, the last closing parenthesis should *not* be part of the + * URL itself, and this method will return `true`. + * + * @private + * @param {String} matchStr The full match string from the {@link #matcherRegex}. + * @return {Boolean} `true` if there is an unbalanced closing parenthesis at + * the end of the `matchStr`, `false` otherwise. + */ + matchHasUnbalancedClosingParen : function( matchStr ) { + var lastChar = matchStr.charAt( matchStr.length - 1 ); + + if( lastChar === ')' ) { + var openParensMatch = matchStr.match( /\(/g ), + closeParensMatch = matchStr.match( /\)/g ), + numOpenParens = ( openParensMatch && openParensMatch.length ) || 0, + numCloseParens = ( closeParensMatch && closeParensMatch.length ) || 0; + + if( numOpenParens < numCloseParens ) { + return true; + } + } + + return false; + } + +} ); +/*global Autolinker */ +/*jshint scripturl:true */ +/** + * @private + * @class Autolinker.MatchValidator + * @extends Object + * + * Used by Autolinker to filter out false positives from the + * {@link Autolinker.matchParser.MatchParser#matcherRegex}. + * + * Due to the limitations of regular expressions (including the missing feature + * of look-behinds in JS regular expressions), we cannot always determine the + * validity of a given match. This class applies a bit of additional logic to + * filter out any false positives that have been matched by the + * {@link Autolinker.matchParser.MatchParser#matcherRegex}. + */ +Autolinker.MatchValidator = Autolinker.Util.extend( Object, { + + /** + * @private + * @property {RegExp} invalidProtocolRelMatchRegex + * + * The regular expression used to check a potential protocol-relative URL + * match, coming from the {@link Autolinker.matchParser.MatchParser#matcherRegex}. + * A protocol-relative URL is, for example, "//yahoo.com" + * + * This regular expression checks to see if there is a word character before + * the '//' match in order to determine if we should actually autolink a + * protocol-relative URL. This is needed because there is no negative + * look-behind in JavaScript regular expressions. + * + * For instance, we want to autolink something like "Go to: //google.com", + * but we don't want to autolink something like "abc//google.com" + */ + invalidProtocolRelMatchRegex : /^[\w]\/\//, + + /** + * Regex to test for a full protocol, with the two trailing slashes. Ex: 'http://' + * + * @private + * @property {RegExp} hasFullProtocolRegex + */ + hasFullProtocolRegex : /^[A-Za-z][-.+A-Za-z0-9]+:\/\//, + + /** + * Regex to find the URI scheme, such as 'mailto:'. + * + * This is used to filter out 'javascript:' and 'vbscript:' schemes. + * + * @private + * @property {RegExp} uriSchemeRegex + */ + uriSchemeRegex : /^[A-Za-z][-.+A-Za-z0-9]+:/, + + /** + * Regex to determine if at least one word char exists after the protocol (i.e. after the ':') + * + * @private + * @property {RegExp} hasWordCharAfterProtocolRegex + */ + hasWordCharAfterProtocolRegex : /:[^\s]*?[A-Za-z]/, + + + /** + * Determines if a given match found by the {@link Autolinker.matchParser.MatchParser} + * is valid. Will return `false` for: + * + * 1) URL matches which do not have at least have one period ('.') in the + * domain name (effectively skipping over matches like "abc:def"). + * However, URL matches with a protocol will be allowed (ex: 'http://localhost') + * 2) URL matches which do not have at least one word character in the + * domain name (effectively skipping over matches like "git:1.0"). + * 3) A protocol-relative url match (a URL beginning with '//') whose + * previous character is a word character (effectively skipping over + * strings like "abc//google.com") + * + * Otherwise, returns `true`. + * + * @param {String} urlMatch The matched URL, if there was one. Will be an + * empty string if the match is not a URL match. + * @param {String} protocolUrlMatch The match URL string for a protocol + * match. Ex: 'http://yahoo.com'. This is used to match something like + * 'http://localhost', where we won't double check that the domain name + * has at least one '.' in it. + * @param {String} protocolRelativeMatch The protocol-relative string for a + * URL match (i.e. '//'), possibly with a preceding character (ex, a + * space, such as: ' //', or a letter, such as: 'a//'). The match is + * invalid if there is a word character preceding the '//'. + * @return {Boolean} `true` if the match given is valid and should be + * processed, or `false` if the match is invalid and/or should just not be + * processed. + */ + isValidMatch : function( urlMatch, protocolUrlMatch, protocolRelativeMatch ) { + if( + ( protocolUrlMatch && !this.isValidUriScheme( protocolUrlMatch ) ) || + this.urlMatchDoesNotHaveProtocolOrDot( urlMatch, protocolUrlMatch ) || // At least one period ('.') must exist in the URL match for us to consider it an actual URL, *unless* it was a full protocol match (like 'http://localhost') + this.urlMatchDoesNotHaveAtLeastOneWordChar( urlMatch, protocolUrlMatch ) || // At least one letter character must exist in the domain name after a protocol match. Ex: skip over something like "git:1.0" + this.isInvalidProtocolRelativeMatch( protocolRelativeMatch ) // A protocol-relative match which has a word character in front of it (so we can skip something like "abc//google.com") + ) { + return false; + } + + return true; + }, + + + /** + * Determines if the URI scheme is a valid scheme to be autolinked. Returns + * `false` if the scheme is 'javascript:' or 'vbscript:' + * + * @private + * @param {String} uriSchemeMatch The match URL string for a full URI scheme + * match. Ex: 'http://yahoo.com' or 'mailto:a@a.com'. + * @return {Boolean} `true` if the scheme is a valid one, `false` otherwise. + */ + isValidUriScheme : function( uriSchemeMatch ) { + var uriScheme = uriSchemeMatch.match( this.uriSchemeRegex )[ 0 ].toLowerCase(); + + return ( uriScheme !== 'javascript:' && uriScheme !== 'vbscript:' ); + }, + + + /** + * Determines if a URL match does not have either: + * + * a) a full protocol (i.e. 'http://'), or + * b) at least one dot ('.') in the domain name (for a non-full-protocol + * match). + * + * Either situation is considered an invalid URL (ex: 'git:d' does not have + * either the '://' part, or at least one dot in the domain name. If the + * match was 'git:abc.com', we would consider this valid.) + * + * @private + * @param {String} urlMatch The matched URL, if there was one. Will be an + * empty string if the match is not a URL match. + * @param {String} protocolUrlMatch The match URL string for a protocol + * match. Ex: 'http://yahoo.com'. This is used to match something like + * 'http://localhost', where we won't double check that the domain name + * has at least one '.' in it. + * @return {Boolean} `true` if the URL match does not have a full protocol, + * or at least one dot ('.') in a non-full-protocol match. + */ + urlMatchDoesNotHaveProtocolOrDot : function( urlMatch, protocolUrlMatch ) { + return ( !!urlMatch && ( !protocolUrlMatch || !this.hasFullProtocolRegex.test( protocolUrlMatch ) ) && urlMatch.indexOf( '.' ) === -1 ); + }, + + + /** + * Determines if a URL match does not have at least one word character after + * the protocol (i.e. in the domain name). + * + * At least one letter character must exist in the domain name after a + * protocol match. Ex: skip over something like "git:1.0" + * + * @private + * @param {String} urlMatch The matched URL, if there was one. Will be an + * empty string if the match is not a URL match. + * @param {String} protocolUrlMatch The match URL string for a protocol + * match. Ex: 'http://yahoo.com'. This is used to know whether or not we + * have a protocol in the URL string, in order to check for a word + * character after the protocol separator (':'). + * @return {Boolean} `true` if the URL match does not have at least one word + * character in it after the protocol, `false` otherwise. + */ + urlMatchDoesNotHaveAtLeastOneWordChar : function( urlMatch, protocolUrlMatch ) { + if( urlMatch && protocolUrlMatch ) { + return !this.hasWordCharAfterProtocolRegex.test( urlMatch ); + } else { + return false; + } + }, + + + /** + * Determines if a protocol-relative match is an invalid one. This method + * returns `true` if there is a `protocolRelativeMatch`, and that match + * contains a word character before the '//' (i.e. it must contain + * whitespace or nothing before the '//' in order to be considered valid). + * + * @private + * @param {String} protocolRelativeMatch The protocol-relative string for a + * URL match (i.e. '//'), possibly with a preceding character (ex, a + * space, such as: ' //', or a letter, such as: 'a//'). The match is + * invalid if there is a word character preceding the '//'. + * @return {Boolean} `true` if it is an invalid protocol-relative match, + * `false` otherwise. + */ + isInvalidProtocolRelativeMatch : function( protocolRelativeMatch ) { + return ( !!protocolRelativeMatch && this.invalidProtocolRelMatchRegex.test( protocolRelativeMatch ) ); + } + +} ); +/*global Autolinker */ +/** + * @abstract + * @class Autolinker.match.Match + * + * Represents a match found in an input string which should be Autolinked. A Match object is what is provided in a + * {@link Autolinker#replaceFn replaceFn}, and may be used to query for details about the match. + * + * For example: + * + * var input = "..."; // string with URLs, Email Addresses, and Twitter Handles + * + * var linkedText = Autolinker.link( input, { + * replaceFn : function( autolinker, match ) { + * console.log( "href = ", match.getAnchorHref() ); + * console.log( "text = ", match.getAnchorText() ); + * + * switch( match.getType() ) { + * case 'url' : + * console.log( "url: ", match.getUrl() ); + * + * case 'email' : + * console.log( "email: ", match.getEmail() ); + * + * case 'twitter' : + * console.log( "twitter: ", match.getTwitterHandle() ); + * } + * } + * } ); + * + * See the {@link Autolinker} class for more details on using the {@link Autolinker#replaceFn replaceFn}. + */ +Autolinker.match.Match = Autolinker.Util.extend( Object, { + + /** + * @cfg {String} matchedText (required) + * + * The original text that was matched. + */ + + + /** + * @constructor + * @param {Object} cfg The configuration properties for the Match instance, specified in an Object (map). + */ + constructor : function( cfg ) { + Autolinker.Util.assign( this, cfg ); + }, + + + /** + * Returns a string name for the type of match that this class represents. + * + * @abstract + * @return {String} + */ + getType : Autolinker.Util.abstractMethod, + + + /** + * Returns the original text that was matched. + * + * @return {String} + */ + getMatchedText : function() { + return this.matchedText; + }, + + + /** + * Returns the anchor href that should be generated for the match. + * + * @abstract + * @return {String} + */ + getAnchorHref : Autolinker.Util.abstractMethod, + + + /** + * Returns the anchor text that should be generated for the match. + * + * @abstract + * @return {String} + */ + getAnchorText : Autolinker.Util.abstractMethod + +} ); +/*global Autolinker */ +/** + * @class Autolinker.match.Email + * @extends Autolinker.match.Match + * + * Represents a Email match found in an input string which should be Autolinked. + * + * See this class's superclass ({@link Autolinker.match.Match}) for more details. + */ +Autolinker.match.Email = Autolinker.Util.extend( Autolinker.match.Match, { + + /** + * @cfg {String} email (required) + * + * The email address that was matched. + */ + + + /** + * Returns a string name for the type of match that this class represents. + * + * @return {String} + */ + getType : function() { + return 'email'; + }, + + + /** + * Returns the email address that was matched. + * + * @return {String} + */ + getEmail : function() { + return this.email; + }, + + + /** + * Returns the anchor href that should be generated for the match. + * + * @return {String} + */ + getAnchorHref : function() { + return 'mailto:' + this.email; + }, + + + /** + * Returns the anchor text that should be generated for the match. + * + * @return {String} + */ + getAnchorText : function() { + return this.email; + } + +} ); +/*global Autolinker */ +/** + * @class Autolinker.match.Hashtag + * @extends Autolinker.match.Match + * + * Represents a Hashtag match found in an input string which should be + * Autolinked. + * + * See this class's superclass ({@link Autolinker.match.Match}) for more + * details. + */ +Autolinker.match.Hashtag = Autolinker.Util.extend( Autolinker.match.Match, { + + /** + * @cfg {String} serviceName (required) + * + * The service to point hashtag matches to. See {@link Autolinker#hashtag} + * for available values. + */ + + /** + * @cfg {String} hashtag (required) + * + * The Hashtag that was matched, without the '#'. + */ + + + /** + * Returns the type of match that this class represents. + * + * @return {String} + */ + getType : function() { + return 'hashtag'; + }, + + + /** + * Returns the matched hashtag. + * + * @return {String} + */ + getHashtag : function() { + return this.hashtag; + }, + + + /** + * Returns the anchor href that should be generated for the match. + * + * @return {String} + */ + getAnchorHref : function() { + var serviceName = this.serviceName, + hashtag = this.hashtag; + + switch( serviceName ) { + case 'twitter' : + return 'https://twitter.com/hashtag/' + hashtag; + case 'facebook' : + return 'https://www.facebook.com/hashtag/' + hashtag; + + default : // Shouldn't happen because Autolinker's constructor should block any invalid values, but just in case. + throw new Error( 'Unknown service name to point hashtag to: ', serviceName ); + } + }, + + + /** + * Returns the anchor text that should be generated for the match. + * + * @return {String} + */ + getAnchorText : function() { + return '#' + this.hashtag; + } + +} ); +/*global Autolinker */ +/** + * @class Autolinker.match.Phone + * @extends Autolinker.match.Match + * + * Represents a Phone number match found in an input string which should be + * Autolinked. + * + * See this class's superclass ({@link Autolinker.match.Match}) for more + * details. + */ +Autolinker.match.Phone = Autolinker.Util.extend( Autolinker.match.Match, { + + /** + * @cfg {String} number (required) + * + * The phone number that was matched. + */ + + + /** + * Returns a string name for the type of match that this class represents. + * + * @return {String} + */ + getType : function() { + return 'phone'; + }, + + + /** + * Returns the phone number that was matched. + * + * @return {String} + */ + getNumber: function() { + return this.number; + }, + + + /** + * Returns the anchor href that should be generated for the match. + * + * @return {String} + */ + getAnchorHref : function() { + return 'tel:' + this.number; + }, + + + /** + * Returns the anchor text that should be generated for the match. + * + * @return {String} + */ + getAnchorText : function() { + return this.matchedText; + } + +} ); + +/*global Autolinker */ +/** + * @class Autolinker.match.Twitter + * @extends Autolinker.match.Match + * + * Represents a Twitter match found in an input string which should be Autolinked. + * + * See this class's superclass ({@link Autolinker.match.Match}) for more details. + */ +Autolinker.match.Twitter = Autolinker.Util.extend( Autolinker.match.Match, { + + /** + * @cfg {String} twitterHandle (required) + * + * The Twitter handle that was matched. + */ + + + /** + * Returns the type of match that this class represents. + * + * @return {String} + */ + getType : function() { + return 'twitter'; + }, + + + /** + * Returns a string name for the type of match that this class represents. + * + * @return {String} + */ + getTwitterHandle : function() { + return this.twitterHandle; + }, + + + /** + * Returns the anchor href that should be generated for the match. + * + * @return {String} + */ + getAnchorHref : function() { + return 'https://twitter.com/' + this.twitterHandle; + }, + + + /** + * Returns the anchor text that should be generated for the match. + * + * @return {String} + */ + getAnchorText : function() { + return '@' + this.twitterHandle; + } + +} ); +/*global Autolinker */ +/** + * @class Autolinker.match.Url + * @extends Autolinker.match.Match + * + * Represents a Url match found in an input string which should be Autolinked. + * + * See this class's superclass ({@link Autolinker.match.Match}) for more details. + */ +Autolinker.match.Url = Autolinker.Util.extend( Autolinker.match.Match, { + + /** + * @cfg {String} url (required) + * + * The url that was matched. + */ + + /** + * @cfg {Boolean} protocolUrlMatch (required) + * + * `true` if the URL is a match which already has a protocol (i.e. 'http://'), `false` if the match was from a 'www' or + * known TLD match. + */ + + /** + * @cfg {Boolean} protocolRelativeMatch (required) + * + * `true` if the URL is a protocol-relative match. A protocol-relative match is a URL that starts with '//', + * and will be either http:// or https:// based on the protocol that the site is loaded under. + */ + + /** + * @cfg {Boolean} stripPrefix (required) + * @inheritdoc Autolinker#stripPrefix + */ + + + /** + * @private + * @property {RegExp} urlPrefixRegex + * + * A regular expression used to remove the 'http://' or 'https://' and/or the 'www.' from URLs. + */ + urlPrefixRegex: /^(https?:\/\/)?(www\.)?/i, + + /** + * @private + * @property {RegExp} protocolRelativeRegex + * + * The regular expression used to remove the protocol-relative '//' from the {@link #url} string, for purposes + * of {@link #getAnchorText}. A protocol-relative URL is, for example, "//yahoo.com" + */ + protocolRelativeRegex : /^\/\//, + + /** + * @private + * @property {Boolean} protocolPrepended + * + * Will be set to `true` if the 'http://' protocol has been prepended to the {@link #url} (because the + * {@link #url} did not have a protocol) + */ + protocolPrepended : false, + + + /** + * Returns a string name for the type of match that this class represents. + * + * @return {String} + */ + getType : function() { + return 'url'; + }, + + + /** + * Returns the url that was matched, assuming the protocol to be 'http://' if the original + * match was missing a protocol. + * + * @return {String} + */ + getUrl : function() { + var url = this.url; + + // if the url string doesn't begin with a protocol, assume 'http://' + if( !this.protocolRelativeMatch && !this.protocolUrlMatch && !this.protocolPrepended ) { + url = this.url = 'http://' + url; + + this.protocolPrepended = true; + } + + return url; + }, + + + /** + * Returns the anchor href that should be generated for the match. + * + * @return {String} + */ + getAnchorHref : function() { + var url = this.getUrl(); + + return url.replace( /&/g, '&' ); // any &'s in the URL should be converted back to '&' if they were displayed as & in the source html + }, + + + /** + * Returns the anchor text that should be generated for the match. + * + * @return {String} + */ + getAnchorText : function() { + var anchorText = this.getUrl(); + + if( this.protocolRelativeMatch ) { + // Strip off any protocol-relative '//' from the anchor text + anchorText = this.stripProtocolRelativePrefix( anchorText ); + } + if( this.stripPrefix ) { + anchorText = this.stripUrlPrefix( anchorText ); + } + anchorText = this.removeTrailingSlash( anchorText ); // remove trailing slash, if there is one + + return anchorText; + }, + + + // --------------------------------------- + + // Utility Functionality + + /** + * Strips the URL prefix (such as "http://" or "https://") from the given text. + * + * @private + * @param {String} text The text of the anchor that is being generated, for which to strip off the + * url prefix (such as stripping off "http://") + * @return {String} The `anchorText`, with the prefix stripped. + */ + stripUrlPrefix : function( text ) { + return text.replace( this.urlPrefixRegex, '' ); + }, + + + /** + * Strips any protocol-relative '//' from the anchor text. + * + * @private + * @param {String} text The text of the anchor that is being generated, for which to strip off the + * protocol-relative prefix (such as stripping off "//") + * @return {String} The `anchorText`, with the protocol-relative prefix stripped. + */ + stripProtocolRelativePrefix : function( text ) { + return text.replace( this.protocolRelativeRegex, '' ); + }, + + + /** + * Removes any trailing slash from the given `anchorText`, in preparation for the text to be displayed. + * + * @private + * @param {String} anchorText The text of the anchor that is being generated, for which to remove any trailing + * slash ('/') that may exist. + * @return {String} The `anchorText`, with the trailing slash removed. + */ + removeTrailingSlash : function( anchorText ) { + if( anchorText.charAt( anchorText.length - 1 ) === '/' ) { + anchorText = anchorText.slice( 0, -1 ); + } + return anchorText; + } + +} ); +return Autolinker; + +})); diff --git a/app/assets/javascripts/lib/attachMediaStream.js b/app/assets/javascripts/lib/attachMediaStream.js new file mode 100644 index 00000000..de3feef5 --- /dev/null +++ b/app/assets/javascripts/lib/attachMediaStream.js @@ -0,0 +1,39 @@ +var attachMediaStream = function (stream, el, options) { + var URL = window.URL; + var opts = { + autoplay: true, + mirror: false, + muted: false + }; + var element = el || document.createElement('video'); + var item; + + if (options) { + for (item in options) { + opts[item] = options[item]; + } + } + + if (opts.autoplay) element.autoplay = 'autoplay'; + if (opts.muted) element.muted = true; + if (opts.mirror) { + ['', 'moz', 'webkit', 'o', 'ms'].forEach(function (prefix) { + var styleName = prefix ? prefix + 'Transform' : 'transform'; + element.style[styleName] = 'scaleX(-1)'; + }); + } + + // this first one should work most everywhere now + // but we have a few fallbacks just in case. + if (URL && URL.createObjectURL) { + element.src = URL.createObjectURL(stream); + } else if (element.srcObject) { + element.srcObject = stream; + } else if (element.mozSrcObject) { + element.mozSrcObject = stream; + } else { + return false; + } + + return element; + }; \ No newline at end of file diff --git a/app/assets/javascripts/lib/cloudcarousel.js b/app/assets/javascripts/lib/cloudcarousel.js index db7633c7..b37abfd7 100644 --- a/app/assets/javascripts/lib/cloudcarousel.js +++ b/app/assets/javascripts/lib/cloudcarousel.js @@ -1,426 +1,426 @@ -////////////////////////////////////////////////////////////////////////////////// -// CloudCarousel V1.0.5 -// (c) 2011 by R Cecco. -// MIT License -// -// Reflection code based on plugin by Christophe Beyls -// -// Please retain this copyright header in all versions of the software -////////////////////////////////////////////////////////////////////////////////// -var matched, browser; - -jQuery.uaMatch = function( ua ) { - ua = ua.toLowerCase(); - - var match = /(chrome)[ \/]([\w.]+)/.exec( ua ) || - /(webkit)[ \/]([\w.]+)/.exec( ua ) || - /(opera)(?:.*version|)[ \/]([\w.]+)/.exec( ua ) || - /(msie) ([\w.]+)/.exec( ua ) || - ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec( ua ) || - []; - - return { - browser: match[ 1 ] || "", - version: match[ 2 ] || "0" - }; -}; - -matched = jQuery.uaMatch( navigator.userAgent ); -browser = {}; - -if ( matched.browser ) { - browser[ matched.browser ] = true; - browser.version = matched.version; -} - -// Chrome is Webkit, but Webkit is also Safari. -if ( browser.chrome ) { - browser.webkit = true; -} else if ( browser.webkit ) { - browser.safari = true; -} - -jQuery.browser = browser; - -(function($) { - - // START Reflection object. - // Creates a reflection for underneath an image. - // IE uses an image with IE specific filter properties, other browsers use the Canvas tag. - // The position and size of the reflection gets updated by updateAll() in Controller. - function Reflection(img, reflHeight, opacity) { - - var reflection, cntx, imageWidth = img.width, imageHeight = img.width, gradient, parent; - - parent = $(img.parentNode); - this.element = reflection = parent.append("").find(':last')[0]; - if ( !reflection.getContext && $.browser.msie) { - this.element = reflection = parent.append("").find(':last')[0]; - reflection.src = img.src; - reflection.style.filter = "flipv progid:DXImageTransform.Microsoft.Alpha(opacity=" + (opacity * 100) + ", style=1, finishOpacity=0, startx=0, starty=0, finishx=0, finishy=" + (reflHeight / imageHeight * 100) + ")"; - - } else { - cntx = reflection.getContext("2d"); - try { - - - $(reflection).attr({width: imageWidth, height: reflHeight}); - cntx.save(); - cntx.translate(0, imageHeight-1); - cntx.scale(1, -1); - cntx.drawImage(img, 0, 0, imageWidth, imageHeight); - cntx.restore(); - cntx.globalCompositeOperation = "destination-out"; - gradient = cntx.createLinearGradient(0, 0, 0, reflHeight); - gradient.addColorStop(0, "rgba(255, 255, 255, " + (1 - opacity) + ")"); - gradient.addColorStop(1, "rgba(255, 255, 255, 1.0)"); - cntx.fillStyle = gradient; - cntx.fillRect(0, 0, imageWidth, reflHeight); - } catch(e) { - return; - } - } - // Store a copy of the alt and title attrs into the reflection - $(reflection).attr({ 'alt': $(img).attr('alt'), title: $(img).attr('title')} ); - - } //END Reflection object - - // START Item object. - // A wrapper object for items within the carousel. - var Item = function(imgIn, options) - { - this.orgWidth = imgIn.width; - this.orgHeight = imgIn.height; - this.image = imgIn; - this.reflection = null; - this.alt = imgIn.alt; - this.title = imgIn.title; - this.imageOK = false; - this.options = options; - - this.imageOK = true; - - if (this.options.reflHeight > 0) - { - this.reflection = new Reflection(this.image, this.options.reflHeight, this.options.reflOpacity); - } - $(this.image).css('position','absolute'); // Bizarre. This seems to reset image width to 0 on webkit! - };// END Item object - - - // Controller object. - // This handles moving all the items, dealing with mouse clicks etc. - var Controller = function(container, images, options) - { - var items = [], funcSin = Math.sin, funcCos = Math.cos, ctx=this; - this.controlTimer = 0; - this.stopped = false; - //this.imagesLoaded = 0; - this.container = container; - this.xRadius = options.xRadius; - this.yRadius = options.yRadius; - this.showFrontTextTimer = 0; - this.autoRotateTimer = 0; - if (options.xRadius === 0) - { - this.xRadius = ($(container).width()/2.3); - } - if (options.yRadius === 0) - { - this.yRadius = ($(container).height()/6); - } - - this.xCentre = options.xPos; - this.yCentre = options.yPos; - this.frontIndex = 0; // Index of the item at the front - - // Start with the first item at the front. - this.rotation = this.destRotation = Math.PI/2; - this.timeDelay = 1000/options.FPS; - - // Turn on the infoBox - if(options.altBox !== null) - { - $(options.altBox).css('display','block'); - $(options.titleBox).css('display','block'); - } - // Turn on relative position for container to allow absolutely positioned elements - // within it to work. - $(container).css({ position:'relative', overflow:'hidden'} ); - - $(options.buttonLeft).css('display','inline'); - $(options.buttonRight).css('display','inline'); - - // Setup the buttons. - $(options.buttonLeft).bind('mouseup',this,function(event){ - event.data.rotate(-1); - return false; - }); - $(options.buttonRight).bind('mouseup',this,function(event){ - event.data.rotate(1); - return false; - }); - - // Add code that makes tab and shift+tab scroll through metacodes - $('.new_topic').bind('keydown',this,function(event){ - if (event.keyCode == 9 && event.shiftKey) { - event.data.rotate(-1); - event.preventDefault(); - event.stopPropagation(); - } else if (event.keyCode == 9) { - event.data.rotate(1); - event.preventDefault(); - event.stopPropagation(); - } - }); - - // You will need this plugin for the mousewheel to work: http://plugins.jquery.com/project/mousewheel - if (options.mouseWheel) - { - // START METAMAPS CODE - $('body').bind('mousewheel',this,function(event, delta) { - if (Metamaps.Create.newTopic.beingCreated && !Metamaps.Create.isSwitchingSet) { - event.data.rotate(delta); - return false; - } - }); - // END METAMAPS CODE - /* ORIGINAL CODE - $(container).bind('mousewheel',this,function(event, delta) { - event.data.rotate(delta); - return false; - }); - */ - } - $(container).bind('mouseover click',this,function(event){ - - clearInterval(event.data.autoRotateTimer); // Stop auto rotation if mouse over. - var text = $(event.target).attr('alt'); - // If we have moved over a carousel item, then show the alt and title text. - - if ( text !== undefined && text !== null ) - { - - clearTimeout(event.data.showFrontTextTimer); - $(options.altBox).html( ($(event.target).attr('alt') )); - //$(options.titleBox).html( ($(event.target).attr('title') )); - if ( options.bringToFront && event.type == 'click' ) - { - $(options.titleBox).html( ($(event.target).attr('title') )); - // METAMAPS CODE - Metamaps.Create.newTopic.metacode = $(event.target).attr('data-id'); - // NOT METAMAPS CODE - var idx = $(event.target).data('itemIndex'); - var frontIndex = event.data.frontIndex; - //var diff = idx - frontIndex; - var diff = (idx - frontIndex) % images.length; - if (Math.abs(diff) > images.length / 2) { - diff += (diff > 0 ? -images.length : images.length); - } - - event.data.rotate(-diff); - } - } - }); - // If we have moved out of a carousel item (or the container itself), - // restore the text of the front item in 1 second. - $(container).bind('mouseout',this,function(event){ - var context = event.data; - clearTimeout(context.showFrontTextTimer); - context.showFrontTextTimer = setTimeout( function(){context.showFrontText();},1000); - context.autoRotate(); // Start auto rotation. - }); - - // Prevent items from being selected as mouse is moved and clicked in the container. - $(container).bind('mousedown',this,function(event){ - - event.data.container.focus(); - return false; - }); - container.onselectstart = function () { return false; }; // For IE. - - this.innerWrapper = $(container).wrapInner('
').children()[0]; - - // Shows the text from the front most item. - this.showFrontText = function() - { - if ( items[this.frontIndex] === undefined ) { return; } // Images might not have loaded yet. - // METAMAPS CODE - Metamaps.Create.newTopic.metacode = $(items[this.frontIndex].image).attr('data-id'); - //$('img.cloudcarousel').css({"background":"none", "width":"","height":""}); - //$(items[this.frontIndex].image).css({"width":"45px","height":"45px"}); - // NOT METAMAPS CODE - $(options.titleBox).html( $(items[this.frontIndex].image).attr('title')); - $(options.altBox).html( $(items[this.frontIndex].image).attr('alt')); - }; - - this.go = function() - { - if(this.controlTimer !== 0) { return; } - var context = this; - this.controlTimer = setTimeout( function(){context.updateAll();},this.timeDelay); - }; - - this.stop = function() - { - clearTimeout(this.controlTimer); - this.controlTimer = 0; - }; - - - // Starts the rotation of the carousel. Direction is the number (+-) of carousel items to rotate by. - this.rotate = function(direction) - { - this.frontIndex -= direction; - if (this.frontIndex == -1) this.frontIndex = items.length - 1; - this.frontIndex %= items.length; - this.destRotation += ( Math.PI / items.length ) * ( 2*direction ); - this.showFrontText(); - this.go(); - }; - - - this.autoRotate = function() - { - if ( options.autoRotate !== 'no' ) - { - var dir = (options.autoRotate === 'right')? 1 : -1; - this.autoRotateTimer = setInterval( function(){ctx.rotate(dir); }, options.autoRotateDelay ); - } - }; - - // This is the main loop function that moves everything. - this.updateAll = function() - { - var minScale = options.minScale; // This is the smallest scale applied to the furthest item. - var smallRange = (1-minScale) * 0.5; - var w,h,x,y,scale,item,sinVal; - - var change = (this.destRotation - this.rotation); - var absChange = Math.abs(change); - - this.rotation += change * options.speed; - if ( absChange < 0.001 ) { this.rotation = this.destRotation; } - var itemsLen = items.length; - var spacing = (Math.PI / itemsLen) * 2; - //var wrapStyle = null; - var radians = this.rotation; - var isMSIE = $.browser.msie; - - // Turn off display. This can reduce repaints/reflows when making style and position changes in the loop. - // See http://dev.opera.com/articles/view/efficient-javascript/?page=3 - this.innerWrapper.style.display = 'none'; - - var style; - var px = 'px', reflHeight; - var context = this; - for (var i = 0; i>0; // >>0 = Math.foor(). Firefox doesn't like fractional decimals in z-index. - w = img.width = item.orgWidth * scale; - h = img.height = item.orgHeight * scale; - img.style.left = x + px ; - img.style.top = y + px; - if (item.reflection !== null) - { - reflHeight = options.reflHeight * scale; - style = item.reflection.element.style; - style.left = x + px; - style.top = y + h + options.reflGap * scale + px; - style.width = w + px; - if (isMSIE) - { - style.filter.finishy = (reflHeight / h * 100); - }else - { - style.height = reflHeight + px; - } - } - } - radians += spacing; - } - // Turn display back on. - this.innerWrapper.style.display = 'block'; - - // If we have a preceptable change in rotation then loop again next frame. - if ( absChange >= 0.001 ) - { - this.controlTimer = setTimeout( function(){context.updateAll();},this.timeDelay); - }else - { - // Otherwise just stop completely. - this.stop(); - } - }; // END updateAll - - // Create an Item object for each image -// func = function(){return;ctx.updateAll();} ; - - // Check if images have loaded. We need valid widths and heights for the reflections. - this.checkImagesLoaded = function() - { - var i; - for(i=0;i +// MIT License +// +// Reflection code based on plugin by Christophe Beyls +// +// Please retain this copyright header in all versions of the software +////////////////////////////////////////////////////////////////////////////////// +var matched, browser; + +jQuery.uaMatch = function( ua ) { + ua = ua.toLowerCase(); + + var match = /(chrome)[ \/]([\w.]+)/.exec( ua ) || + /(webkit)[ \/]([\w.]+)/.exec( ua ) || + /(opera)(?:.*version|)[ \/]([\w.]+)/.exec( ua ) || + /(msie) ([\w.]+)/.exec( ua ) || + ua.indexOf("compatible") < 0 && /(mozilla)(?:.*? rv:([\w.]+)|)/.exec( ua ) || + []; + + return { + browser: match[ 1 ] || "", + version: match[ 2 ] || "0" + }; +}; + +matched = jQuery.uaMatch( navigator.userAgent ); +browser = {}; + +if ( matched.browser ) { + browser[ matched.browser ] = true; + browser.version = matched.version; +} + +// Chrome is Webkit, but Webkit is also Safari. +if ( browser.chrome ) { + browser.webkit = true; +} else if ( browser.webkit ) { + browser.safari = true; +} + +jQuery.browser = browser; + +(function($) { + + // START Reflection object. + // Creates a reflection for underneath an image. + // IE uses an image with IE specific filter properties, other browsers use the Canvas tag. + // The position and size of the reflection gets updated by updateAll() in Controller. + function Reflection(img, reflHeight, opacity) { + + var reflection, cntx, imageWidth = img.width, imageHeight = img.width, gradient, parent; + + parent = $(img.parentNode); + this.element = reflection = parent.append("").find(':last')[0]; + if ( !reflection.getContext && $.browser.msie) { + this.element = reflection = parent.append("").find(':last')[0]; + reflection.src = img.src; + reflection.style.filter = "flipv progid:DXImageTransform.Microsoft.Alpha(opacity=" + (opacity * 100) + ", style=1, finishOpacity=0, startx=0, starty=0, finishx=0, finishy=" + (reflHeight / imageHeight * 100) + ")"; + + } else { + cntx = reflection.getContext("2d"); + try { + + + $(reflection).attr({width: imageWidth, height: reflHeight}); + cntx.save(); + cntx.translate(0, imageHeight-1); + cntx.scale(1, -1); + cntx.drawImage(img, 0, 0, imageWidth, imageHeight); + cntx.restore(); + cntx.globalCompositeOperation = "destination-out"; + gradient = cntx.createLinearGradient(0, 0, 0, reflHeight); + gradient.addColorStop(0, "rgba(255, 255, 255, " + (1 - opacity) + ")"); + gradient.addColorStop(1, "rgba(255, 255, 255, 1.0)"); + cntx.fillStyle = gradient; + cntx.fillRect(0, 0, imageWidth, reflHeight); + } catch(e) { + return; + } + } + // Store a copy of the alt and title attrs into the reflection + $(reflection).attr({ 'alt': $(img).attr('alt'), title: $(img).attr('title')} ); + + } //END Reflection object + + // START Item object. + // A wrapper object for items within the carousel. + var Item = function(imgIn, options) + { + this.orgWidth = imgIn.width; + this.orgHeight = imgIn.height; + this.image = imgIn; + this.reflection = null; + this.alt = imgIn.alt; + this.title = imgIn.title; + this.imageOK = false; + this.options = options; + + this.imageOK = true; + + if (this.options.reflHeight > 0) + { + this.reflection = new Reflection(this.image, this.options.reflHeight, this.options.reflOpacity); + } + $(this.image).css('position','absolute'); // Bizarre. This seems to reset image width to 0 on webkit! + };// END Item object + + + // Controller object. + // This handles moving all the items, dealing with mouse clicks etc. + var Controller = function(container, images, options) + { + var items = [], funcSin = Math.sin, funcCos = Math.cos, ctx=this; + this.controlTimer = 0; + this.stopped = false; + //this.imagesLoaded = 0; + this.container = container; + this.xRadius = options.xRadius; + this.yRadius = options.yRadius; + this.showFrontTextTimer = 0; + this.autoRotateTimer = 0; + if (options.xRadius === 0) + { + this.xRadius = ($(container).width()/2.3); + } + if (options.yRadius === 0) + { + this.yRadius = ($(container).height()/6); + } + + this.xCentre = options.xPos; + this.yCentre = options.yPos; + this.frontIndex = 0; // Index of the item at the front + + // Start with the first item at the front. + this.rotation = this.destRotation = Math.PI/2; + this.timeDelay = 1000/options.FPS; + + // Turn on the infoBox + if(options.altBox !== null) + { + $(options.altBox).css('display','block'); + $(options.titleBox).css('display','block'); + } + // Turn on relative position for container to allow absolutely positioned elements + // within it to work. + $(container).css({ position:'relative', overflow:'hidden'} ); + + $(options.buttonLeft).css('display','inline'); + $(options.buttonRight).css('display','inline'); + + // Setup the buttons. + $(options.buttonLeft).bind('mouseup',this,function(event){ + event.data.rotate(-1); + return false; + }); + $(options.buttonRight).bind('mouseup',this,function(event){ + event.data.rotate(1); + return false; + }); + + // Add code that makes tab and shift+tab scroll through metacodes + $('.new_topic').bind('keydown',this,function(event){ + if (event.keyCode == 9 && event.shiftKey) { + event.data.rotate(-1); + event.preventDefault(); + event.stopPropagation(); + } else if (event.keyCode == 9) { + event.data.rotate(1); + event.preventDefault(); + event.stopPropagation(); + } + }); + + // You will need this plugin for the mousewheel to work: http://plugins.jquery.com/project/mousewheel + if (options.mouseWheel) + { + // START METAMAPS CODE + $('body').bind('mousewheel',this,function(event, delta) { + if (Metamaps.Create.newTopic.beingCreated && !Metamaps.Create.isSwitchingSet) { + event.data.rotate(delta); + return false; + } + }); + // END METAMAPS CODE + /* ORIGINAL CODE + $(container).bind('mousewheel',this,function(event, delta) { + event.data.rotate(delta); + return false; + }); + */ + } + $(container).bind('mouseover click',this,function(event){ + + clearInterval(event.data.autoRotateTimer); // Stop auto rotation if mouse over. + var text = $(event.target).attr('alt'); + // If we have moved over a carousel item, then show the alt and title text. + + if ( text !== undefined && text !== null ) + { + + clearTimeout(event.data.showFrontTextTimer); + $(options.altBox).html( ($(event.target).attr('alt') )); + //$(options.titleBox).html( ($(event.target).attr('title') )); + if ( options.bringToFront && event.type == 'click' ) + { + $(options.titleBox).html( ($(event.target).attr('title') )); + // METAMAPS CODE + Metamaps.Create.newTopic.metacode = $(event.target).attr('data-id'); + // NOT METAMAPS CODE + var idx = $(event.target).data('itemIndex'); + var frontIndex = event.data.frontIndex; + //var diff = idx - frontIndex; + var diff = (idx - frontIndex) % images.length; + if (Math.abs(diff) > images.length / 2) { + diff += (diff > 0 ? -images.length : images.length); + } + + event.data.rotate(-diff); + } + } + }); + // If we have moved out of a carousel item (or the container itself), + // restore the text of the front item in 1 second. + $(container).bind('mouseout',this,function(event){ + var context = event.data; + clearTimeout(context.showFrontTextTimer); + context.showFrontTextTimer = setTimeout( function(){context.showFrontText();},1000); + context.autoRotate(); // Start auto rotation. + }); + + // Prevent items from being selected as mouse is moved and clicked in the container. + $(container).bind('mousedown',this,function(event){ + + event.data.container.focus(); + return false; + }); + container.onselectstart = function () { return false; }; // For IE. + + this.innerWrapper = $(container).wrapInner('
').children()[0]; + + // Shows the text from the front most item. + this.showFrontText = function() + { + if ( items[this.frontIndex] === undefined ) { return; } // Images might not have loaded yet. + // METAMAPS CODE + Metamaps.Create.newTopic.metacode = $(items[this.frontIndex].image).attr('data-id'); + //$('img.cloudcarousel').css({"background":"none", "width":"","height":""}); + //$(items[this.frontIndex].image).css({"width":"45px","height":"45px"}); + // NOT METAMAPS CODE + $(options.titleBox).html( $(items[this.frontIndex].image).attr('title')); + $(options.altBox).html( $(items[this.frontIndex].image).attr('alt')); + }; + + this.go = function() + { + if(this.controlTimer !== 0) { return; } + var context = this; + this.controlTimer = setTimeout( function(){context.updateAll();},this.timeDelay); + }; + + this.stop = function() + { + clearTimeout(this.controlTimer); + this.controlTimer = 0; + }; + + + // Starts the rotation of the carousel. Direction is the number (+-) of carousel items to rotate by. + this.rotate = function(direction) + { + this.frontIndex -= direction; + if (this.frontIndex == -1) this.frontIndex = items.length - 1; + this.frontIndex %= items.length; + this.destRotation += ( Math.PI / items.length ) * ( 2*direction ); + this.showFrontText(); + this.go(); + }; + + + this.autoRotate = function() + { + if ( options.autoRotate !== 'no' ) + { + var dir = (options.autoRotate === 'right')? 1 : -1; + this.autoRotateTimer = setInterval( function(){ctx.rotate(dir); }, options.autoRotateDelay ); + } + }; + + // This is the main loop function that moves everything. + this.updateAll = function() + { + var minScale = options.minScale; // This is the smallest scale applied to the furthest item. + var smallRange = (1-minScale) * 0.5; + var w,h,x,y,scale,item,sinVal; + + var change = (this.destRotation - this.rotation); + var absChange = Math.abs(change); + + this.rotation += change * options.speed; + if ( absChange < 0.001 ) { this.rotation = this.destRotation; } + var itemsLen = items.length; + var spacing = (Math.PI / itemsLen) * 2; + //var wrapStyle = null; + var radians = this.rotation; + var isMSIE = $.browser.msie; + + // Turn off display. This can reduce repaints/reflows when making style and position changes in the loop. + // See http://dev.opera.com/articles/view/efficient-javascript/?page=3 + this.innerWrapper.style.display = 'none'; + + var style; + var px = 'px', reflHeight; + var context = this; + for (var i = 0; i>0; // >>0 = Math.foor(). Firefox doesn't like fractional decimals in z-index. + w = img.width = item.orgWidth * scale; + h = img.height = item.orgHeight * scale; + img.style.left = x + px ; + img.style.top = y + px; + if (item.reflection !== null) + { + reflHeight = options.reflHeight * scale; + style = item.reflection.element.style; + style.left = x + px; + style.top = y + h + options.reflGap * scale + px; + style.width = w + px; + if (isMSIE) + { + style.filter.finishy = (reflHeight / h * 100); + }else + { + style.height = reflHeight + px; + } + } + } + radians += spacing; + } + // Turn display back on. + this.innerWrapper.style.display = 'block'; + + // If we have a preceptable change in rotation then loop again next frame. + if ( absChange >= 0.001 ) + { + this.controlTimer = setTimeout( function(){context.updateAll();},this.timeDelay); + }else + { + // Otherwise just stop completely. + this.stop(); + } + }; // END updateAll + + // Create an Item object for each image +// func = function(){return;ctx.updateAll();} ; + + // Check if images have loaded. We need valid widths and heights for the reflections. + this.checkImagesLoaded = function() + { + var i; + for(i=0;i= 0 && vol <= 1) { + self._volume = vol; + + if (usingWebAudio) { + masterGain.gain.value = vol; + } + + // loop through cache and change volume of all nodes that are using HTML5 Audio + for (var key in self._howls) { + if (self._howls.hasOwnProperty(key) && self._howls[key]._webAudio === false) { + // loop through the audio nodes + for (var i=0; i 0) ? node._pos : self._sprite[sprite][0] / 1000; + + // determine how long to play for + var duration = 0; + if (self._webAudio) { + duration = self._sprite[sprite][1] / 1000 - node._pos; + if (node._pos > 0) { + pos = self._sprite[sprite][0] / 1000 + pos; + } + } else { + duration = self._sprite[sprite][1] / 1000 - (pos - self._sprite[sprite][0] / 1000); + } + + // determine if this sound should be looped + var loop = !!(self._loop || self._sprite[sprite][2]); + + // set timer to fire the 'onend' event + var soundId = (typeof callback === 'string') ? callback : Math.round(Date.now() * Math.random()) + '', + timerId; + (function() { + var data = { + id: soundId, + sprite: sprite, + loop: loop + }; + timerId = setTimeout(function() { + // if looping, restart the track + if (!self._webAudio && loop) { + self.stop(data.id).play(sprite, data.id); + } + + // set web audio node to paused at end + if (self._webAudio && !loop) { + self._nodeById(data.id).paused = true; + self._nodeById(data.id)._pos = 0; + + // clear the end timer + self._clearEndTimer(data.id); + } + + // end the track if it is HTML audio and a sprite + if (!self._webAudio && !loop) { + self.stop(data.id); + } + + // fire ended event + self.on('end', soundId); + }, duration * 1000); + + // store the reference to the timer + self._onendTimer.push({timer: timerId, id: data.id}); + })(); + + if (self._webAudio) { + var loopStart = self._sprite[sprite][0] / 1000, + loopEnd = self._sprite[sprite][1] / 1000; + + // set the play id to this node and load into context + node.id = soundId; + node.paused = false; + refreshBuffer(self, [loop, loopStart, loopEnd], soundId); + self._playStart = ctx.currentTime; + node.gain.value = self._volume; + + if (typeof node.bufferSource.start === 'undefined') { + loop ? node.bufferSource.noteGrainOn(0, pos, 86400) : node.bufferSource.noteGrainOn(0, pos, duration); + } else { + loop ? node.bufferSource.start(0, pos, 86400) : node.bufferSource.start(0, pos, duration); + } + } else { + if (node.readyState === 4 || !node.readyState && navigator.isCocoonJS) { + node.readyState = 4; + node.id = soundId; + node.currentTime = pos; + node.muted = Howler._muted || node.muted; + node.volume = self._volume * Howler.volume(); + setTimeout(function() { node.play(); }, 0); + } else { + self._clearEndTimer(soundId); + + (function(){ + var sound = self, + playSprite = sprite, + fn = callback, + newNode = node; + var listener = function() { + sound.play(playSprite, fn); + + // clear the event listener + newNode.removeEventListener('canplaythrough', listener, false); + }; + newNode.addEventListener('canplaythrough', listener, false); + })(); + + return self; + } + } + + // fire the play event and send the soundId back in the callback + self.on('play'); + if (typeof callback === 'function') callback(soundId); + + return self; + }); + + return self; + }, + + /** + * Pause playback and save the current position. + * @param {String} id (optional) The play instance ID. + * @return {Howl} + */ + pause: function(id) { + var self = this; + + // if the sound hasn't been loaded, add it to the event queue + if (!self._loaded) { + self.on('play', function() { + self.pause(id); + }); + + return self; + } + + // clear 'onend' timer + self._clearEndTimer(id); + + var activeNode = (id) ? self._nodeById(id) : self._activeNode(); + if (activeNode) { + activeNode._pos = self.pos(null, id); + + if (self._webAudio) { + // make sure the sound has been created + if (!activeNode.bufferSource || activeNode.paused) { + return self; + } + + activeNode.paused = true; + if (typeof activeNode.bufferSource.stop === 'undefined') { + activeNode.bufferSource.noteOff(0); + } else { + activeNode.bufferSource.stop(0); + } + } else { + activeNode.pause(); + } + } + + self.on('pause'); + + return self; + }, + + /** + * Stop playback and reset to start. + * @param {String} id (optional) The play instance ID. + * @return {Howl} + */ + stop: function(id) { + var self = this; + + // if the sound hasn't been loaded, add it to the event queue + if (!self._loaded) { + self.on('play', function() { + self.stop(id); + }); + + return self; + } + + // clear 'onend' timer + self._clearEndTimer(id); + + var activeNode = (id) ? self._nodeById(id) : self._activeNode(); + if (activeNode) { + activeNode._pos = 0; + + if (self._webAudio) { + // make sure the sound has been created + if (!activeNode.bufferSource || activeNode.paused) { + return self; + } + + activeNode.paused = true; + + if (typeof activeNode.bufferSource.stop === 'undefined') { + activeNode.bufferSource.noteOff(0); + } else { + activeNode.bufferSource.stop(0); + } + } else if (!isNaN(activeNode.duration)) { + activeNode.pause(); + activeNode.currentTime = 0; + } + } + + return self; + }, + + /** + * Mute this sound. + * @param {String} id (optional) The play instance ID. + * @return {Howl} + */ + mute: function(id) { + var self = this; + + // if the sound hasn't been loaded, add it to the event queue + if (!self._loaded) { + self.on('play', function() { + self.mute(id); + }); + + return self; + } + + var activeNode = (id) ? self._nodeById(id) : self._activeNode(); + if (activeNode) { + if (self._webAudio) { + activeNode.gain.value = 0; + } else { + activeNode.muted = true; + } + } + + return self; + }, + + /** + * Unmute this sound. + * @param {String} id (optional) The play instance ID. + * @return {Howl} + */ + unmute: function(id) { + var self = this; + + // if the sound hasn't been loaded, add it to the event queue + if (!self._loaded) { + self.on('play', function() { + self.unmute(id); + }); + + return self; + } + + var activeNode = (id) ? self._nodeById(id) : self._activeNode(); + if (activeNode) { + if (self._webAudio) { + activeNode.gain.value = self._volume; + } else { + activeNode.muted = false; + } + } + + return self; + }, + + /** + * Get/set volume of this sound. + * @param {Float} vol Volume from 0.0 to 1.0. + * @param {String} id (optional) The play instance ID. + * @return {Howl/Float} Returns self or current volume. + */ + volume: function(vol, id) { + var self = this; + + // make sure volume is a number + vol = parseFloat(vol); + + if (vol >= 0 && vol <= 1) { + self._volume = vol; + + // if the sound hasn't been loaded, add it to the event queue + if (!self._loaded) { + self.on('play', function() { + self.volume(vol, id); + }); + + return self; + } + + var activeNode = (id) ? self._nodeById(id) : self._activeNode(); + if (activeNode) { + if (self._webAudio) { + activeNode.gain.value = vol; + } else { + activeNode.volume = vol * Howler.volume(); + } + } + + return self; + } else { + return self._volume; + } + }, + + /** + * Get/set whether to loop the sound. + * @param {Boolean} loop To loop or not to loop, that is the question. + * @return {Howl/Boolean} Returns self or current looping value. + */ + loop: function(loop) { + var self = this; + + if (typeof loop === 'boolean') { + self._loop = loop; + + return self; + } else { + return self._loop; + } + }, + + /** + * Get/set sound sprite definition. + * @param {Object} sprite Example: {spriteName: [offset, duration, loop]} + * @param {Integer} offset Where to begin playback in milliseconds + * @param {Integer} duration How long to play in milliseconds + * @param {Boolean} loop (optional) Set true to loop this sprite + * @return {Howl} Returns current sprite sheet or self. + */ + sprite: function(sprite) { + var self = this; + + if (typeof sprite === 'object') { + self._sprite = sprite; + + return self; + } else { + return self._sprite; + } + }, + + /** + * Get/set the position of playback. + * @param {Float} pos The position to move current playback to. + * @param {String} id (optional) The play instance ID. + * @return {Howl/Float} Returns self or current playback position. + */ + pos: function(pos, id) { + var self = this; + + // if the sound hasn't been loaded, add it to the event queue + if (!self._loaded) { + self.on('load', function() { + self.pos(pos); + }); + + return typeof pos === 'number' ? self : self._pos || 0; + } + + // make sure we are dealing with a number for pos + pos = parseFloat(pos); + + var activeNode = (id) ? self._nodeById(id) : self._activeNode(); + if (activeNode) { + if (pos >= 0) { + self.pause(id); + activeNode._pos = pos; + self.play(activeNode._sprite, id); + + return self; + } else { + return self._webAudio ? activeNode._pos + (ctx.currentTime - self._playStart) : activeNode.currentTime; + } + } else if (pos >= 0) { + return self; + } else { + // find the first inactive node to return the pos for + for (var i=0; i= 0 || x < 0) { + if (self._webAudio) { + var activeNode = (id) ? self._nodeById(id) : self._activeNode(); + if (activeNode) { + self._pos3d = [x, y, z]; + activeNode.panner.setPosition(x, y, z); + activeNode.panner.panningModel = self._model || 'HRTF'; + } + } + } else { + return self._pos3d; + } + + return self; + }, + + /** + * Fade a currently playing sound between two volumes. + * @param {Number} from The volume to fade from (0.0 to 1.0). + * @param {Number} to The volume to fade to (0.0 to 1.0). + * @param {Number} len Time in milliseconds to fade. + * @param {Function} callback (optional) Fired when the fade is complete. + * @param {String} id (optional) The play instance ID. + * @return {Howl} + */ + fade: function(from, to, len, callback, id) { + var self = this, + diff = Math.abs(from - to), + dir = from > to ? 'down' : 'up', + steps = diff / 0.01, + stepTime = len / steps; + + // if the sound hasn't been loaded, add it to the event queue + if (!self._loaded) { + self.on('load', function() { + self.fade(from, to, len, callback, id); + }); + + return self; + } + + // set the volume to the start position + self.volume(from, id); + + for (var i=1; i<=steps; i++) { + (function() { + var change = self._volume + (dir === 'up' ? 0.01 : -0.01) * i, + vol = Math.round(1000 * change) / 1000, + toVol = to; + + setTimeout(function() { + self.volume(vol, id); + + if (vol === toVol) { + if (callback) callback(); + } + }, stepTime * i); + })(); + } + }, + + /** + * [DEPRECATED] Fade in the current sound. + * @param {Float} to Volume to fade to (0.0 to 1.0). + * @param {Number} len Time in milliseconds to fade. + * @param {Function} callback + * @return {Howl} + */ + fadeIn: function(to, len, callback) { + return this.volume(0).play().fade(0, to, len, callback); + }, + + /** + * [DEPRECATED] Fade out the current sound and pause when finished. + * @param {Float} to Volume to fade to (0.0 to 1.0). + * @param {Number} len Time in milliseconds to fade. + * @param {Function} callback + * @param {String} id (optional) The play instance ID. + * @return {Howl} + */ + fadeOut: function(to, len, callback, id) { + var self = this; + + return self.fade(self._volume, to, len, function() { + if (callback) callback(); + self.pause(id); + + // fire ended event + self.on('end'); + }, id); + }, + + /** + * Get an audio node by ID. + * @return {Howl} Audio node. + */ + _nodeById: function(id) { + var self = this, + node = self._audioNode[0]; + + // find the node with this ID + for (var i=0; i=0; i--) { + if (inactive <= 5) { + break; + } + + if (self._audioNode[i].paused) { + // disconnect the audio source if using Web Audio + if (self._webAudio) { + self._audioNode[i].disconnect(0); + } + + inactive--; + self._audioNode.splice(i, 1); + } + } + }, + + /** + * Clear 'onend' timeout before it ends. + * @param {String} soundId The play instance ID. + */ + _clearEndTimer: function(soundId) { + var self = this, + index = 0; + + // loop through the timers to find the one associated with this sound + for (var i=0; i= 0) { + Howler._howls.splice(index, 1); + } + + // delete this sound from the cache + delete cache[self._src]; + self = null; + } + + }; + + // only define these functions when using WebAudio + if (usingWebAudio) { + + /** + * Buffer a sound from URL (or from cache) and decode to audio source (Web Audio API). + * @param {Object} obj The Howl object for the sound to load. + * @param {String} url The path to the sound file. + */ + var loadBuffer = function(obj, url) { + // check if the buffer has already been cached + if (url in cache) { + // set the duration from the cache + obj._duration = cache[url].duration; + + // load the sound into this object + loadSound(obj); + return; + } + + if (/^data:[^;]+;base64,/.test(url)) { + // Decode base64 data-URIs because some browsers cannot load data-URIs with XMLHttpRequest. + var data = atob(url.split(',')[1]); + var dataView = new Uint8Array(data.length); + for (var i=0; iDOM 2 EventTarget Interface} + * + * @param {string} type + * @param {function} listener + * @param {boolean} useCapture + * @return void + */ + WebSocket.prototype.addEventListener = function(type, listener, useCapture) { + if (!(type in this.__events)) { + this.__events[type] = []; + } + this.__events[type].push(listener); + }; + + /** + * Implementation of {@link DOM 2 EventTarget Interface} + * + * @param {string} type + * @param {function} listener + * @param {boolean} useCapture + * @return void + */ + WebSocket.prototype.removeEventListener = function(type, listener, useCapture) { + if (!(type in this.__events)) return; + var events = this.__events[type]; + for (var i = events.length - 1; i >= 0; --i) { + if (events[i] === listener) { + events.splice(i, 1); + break; + } + } + }; + + /** + * Implementation of {@link DOM 2 EventTarget Interface} + * + * @param {Event} event + * @return void + */ + WebSocket.prototype.dispatchEvent = function(event) { + var events = this.__events[event.type] || []; + for (var i = 0; i < events.length; ++i) { + events[i](event); + } + var handler = this["on" + event.type]; + if (handler) handler(event); + }; + + /** + * Handles an event from Flash. + * @param {Object} flashEvent + */ + WebSocket.prototype.__handleEvent = function(flashEvent) { + if ("readyState" in flashEvent) { + this.readyState = flashEvent.readyState; + } + if ("protocol" in flashEvent) { + this.protocol = flashEvent.protocol; + } + + var jsEvent; + if (flashEvent.type == "open" || flashEvent.type == "error") { + jsEvent = this.__createSimpleEvent(flashEvent.type); + } else if (flashEvent.type == "close") { + // TODO implement jsEvent.wasClean + jsEvent = this.__createSimpleEvent("close"); + } else if (flashEvent.type == "message") { + var data = decodeURIComponent(flashEvent.message); + jsEvent = this.__createMessageEvent("message", data); + } else { + throw "unknown event type: " + flashEvent.type; + } + + this.dispatchEvent(jsEvent); + }; + + WebSocket.prototype.__createSimpleEvent = function(type) { + if (document.createEvent && window.Event) { + var event = document.createEvent("Event"); + event.initEvent(type, false, false); + return event; + } else { + return {type: type, bubbles: false, cancelable: false}; + } + }; + + WebSocket.prototype.__createMessageEvent = function(type, data) { + if (document.createEvent && window.MessageEvent && !window.opera) { + var event = document.createEvent("MessageEvent"); + event.initMessageEvent("message", false, false, data, null, null, window, null); + return event; + } else { + // IE and Opera, the latter one truncates the data parameter after any 0x00 bytes. + return {type: type, data: data, bubbles: false, cancelable: false}; + } + }; + + /** + * Define the WebSocket readyState enumeration. + */ + WebSocket.CONNECTING = 0; + WebSocket.OPEN = 1; + WebSocket.CLOSING = 2; + WebSocket.CLOSED = 3; + + WebSocket.__flash = null; + WebSocket.__instances = {}; + WebSocket.__tasks = []; + WebSocket.__nextId = 0; + + /** + * Load a new flash security policy file. + * @param {string} url + */ + WebSocket.loadFlashPolicyFile = function(url){ + WebSocket.__addTask(function() { + WebSocket.__flash.loadManualPolicyFile(url); + }); + }; + + /** + * Loads WebSocketMain.swf and creates WebSocketMain object in Flash. + */ + WebSocket.__initialize = function() { + if (WebSocket.__flash) return; + + if (WebSocket.__swfLocation) { + // For backword compatibility. + window.WEB_SOCKET_SWF_LOCATION = WebSocket.__swfLocation; + } + if (!window.WEB_SOCKET_SWF_LOCATION) { + console.error("[WebSocket] set WEB_SOCKET_SWF_LOCATION to location of WebSocketMain.swf"); + return; + } + var container = document.createElement("div"); + container.id = "webSocketContainer"; + // Hides Flash box. We cannot use display: none or visibility: hidden because it prevents + // Flash from loading at least in IE. So we move it out of the screen at (-100, -100). + // But this even doesn't work with Flash Lite (e.g. in Droid Incredible). So with Flash + // Lite, we put it at (0, 0). This shows 1x1 box visible at left-top corner but this is + // the best we can do as far as we know now. + container.style.position = "absolute"; + if (WebSocket.__isFlashLite()) { + container.style.left = "0px"; + container.style.top = "0px"; + } else { + container.style.left = "-100px"; + container.style.top = "-100px"; + } + var holder = document.createElement("div"); + holder.id = "webSocketFlash"; + container.appendChild(holder); + document.body.appendChild(container); + // See this article for hasPriority: + // http://help.adobe.com/en_US/as3/mobile/WS4bebcd66a74275c36cfb8137124318eebc6-7ffd.html + swfobject.embedSWF( + WEB_SOCKET_SWF_LOCATION, + "webSocketFlash", + "1" /* width */, + "1" /* height */, + "10.0.0" /* SWF version */, + null, + null, + {hasPriority: true, swliveconnect : true, allowScriptAccess: "always"}, + null, + function(e) { + if (!e.success) { + console.error("[WebSocket] swfobject.embedSWF failed"); + } + }); + }; + + /** + * Called by Flash to notify JS that it's fully loaded and ready + * for communication. + */ + WebSocket.__onFlashInitialized = function() { + // We need to set a timeout here to avoid round-trip calls + // to flash during the initialization process. + setTimeout(function() { + WebSocket.__flash = document.getElementById("webSocketFlash"); + WebSocket.__flash.setCallerUrl(location.href); + WebSocket.__flash.setDebug(!!window.WEB_SOCKET_DEBUG); + for (var i = 0; i < WebSocket.__tasks.length; ++i) { + WebSocket.__tasks[i](); + } + WebSocket.__tasks = []; + }, 0); + }; + + /** + * Called by Flash to notify WebSockets events are fired. + */ + WebSocket.__onFlashEvent = function() { + setTimeout(function() { + try { + // Gets events using receiveEvents() instead of getting it from event object + // of Flash event. This is to make sure to keep message order. + // It seems sometimes Flash events don't arrive in the same order as they are sent. + var events = WebSocket.__flash.receiveEvents(); + for (var i = 0; i < events.length; ++i) { + WebSocket.__instances[events[i].webSocketId].__handleEvent(events[i]); + } + } catch (e) { + console.error(e); + } + }, 0); + return true; + }; + + // Called by Flash. + WebSocket.__log = function(message) { + console.log(decodeURIComponent(message)); + }; + + // Called by Flash. + WebSocket.__error = function(message) { + console.error(decodeURIComponent(message)); + }; + + WebSocket.__addTask = function(task) { + if (WebSocket.__flash) { + task(); + } else { + WebSocket.__tasks.push(task); + } + }; + + /** + * Test if the browser is running flash lite. + * @return {boolean} True if flash lite is running, false otherwise. + */ + WebSocket.__isFlashLite = function() { + if (!window.navigator || !window.navigator.mimeTypes) { + return false; + } + var mimeType = window.navigator.mimeTypes["application/x-shockwave-flash"]; + if (!mimeType || !mimeType.enabledPlugin || !mimeType.enabledPlugin.filename) { + return false; + } + return mimeType.enabledPlugin.filename.match(/flashlite/i) ? true : false; + }; + + if (!window.WEB_SOCKET_DISABLE_AUTO_INITIALIZATION) { + if (window.addEventListener) { + window.addEventListener("load", function(){ + WebSocket.__initialize(); + }, false); + } else { + window.attachEvent("onload", function(){ + WebSocket.__initialize(); + }); + } + } + +})(); + +/** + * socket.io + * Copyright(c) 2011 LearnBoost + * MIT Licensed + */ + +(function (exports, io, global) { + + /** + * Expose constructor. + * + * @api public + */ + + exports.XHR = XHR; + + /** + * XHR constructor + * + * @costructor + * @api public + */ + + function XHR (socket) { + if (!socket) return; + + io.Transport.apply(this, arguments); + this.sendBuffer = []; + }; + + /** + * Inherits from Transport. + */ + + io.util.inherit(XHR, io.Transport); + + /** + * Establish a connection + * + * @returns {Transport} + * @api public + */ + + XHR.prototype.open = function () { + this.socket.setBuffer(false); + this.onOpen(); + this.get(); + + // we need to make sure the request succeeds since we have no indication + // whether the request opened or not until it succeeded. + this.setCloseTimeout(); + + return this; + }; + + /** + * Check if we need to send data to the Socket.IO server, if we have data in our + * buffer we encode it and forward it to the `post` method. + * + * @api private + */ + + XHR.prototype.payload = function (payload) { + var msgs = []; + + for (var i = 0, l = payload.length; i < l; i++) { + msgs.push(io.parser.encodePacket(payload[i])); + } + + this.send(io.parser.encodePayload(msgs)); + }; + + /** + * Send data to the Socket.IO server. + * + * @param data The message + * @returns {Transport} + * @api public + */ + + XHR.prototype.send = function (data) { + this.post(data); + return this; + }; + + /** + * Posts a encoded message to the Socket.IO server. + * + * @param {String} data A encoded message. + * @api private + */ + + function empty () { }; + + XHR.prototype.post = function (data) { + var self = this; + this.socket.setBuffer(true); + + function stateChange () { + if (this.readyState == 4) { + this.onreadystatechange = empty; + self.posting = false; + + if (this.status == 200){ + self.socket.setBuffer(false); + } else { + self.onClose(); + } + } + } + + function onload () { + this.onload = empty; + self.socket.setBuffer(false); + }; + + this.sendXHR = this.request('POST'); + + if (global.XDomainRequest && this.sendXHR instanceof XDomainRequest) { + this.sendXHR.onload = this.sendXHR.onerror = onload; + } else { + this.sendXHR.onreadystatechange = stateChange; + } + + this.sendXHR.send(data); + }; + + /** + * Disconnects the established `XHR` connection. + * + * @returns {Transport} + * @api public + */ + + XHR.prototype.close = function () { + this.onClose(); + return this; + }; + + /** + * Generates a configured XHR request + * + * @param {String} url The url that needs to be requested. + * @param {String} method The method the request should use. + * @returns {XMLHttpRequest} + * @api private + */ + + XHR.prototype.request = function (method) { + var req = io.util.request(this.socket.isXDomain()) + , query = io.util.query(this.socket.options.query, 't=' + +new Date); + + req.open(method || 'GET', this.prepareUrl() + query, true); + + if (method == 'POST') { + try { + if (req.setRequestHeader) { + req.setRequestHeader('Content-type', 'text/plain;charset=UTF-8'); + } else { + // XDomainRequest + req.contentType = 'text/plain'; + } + } catch (e) {} + } + + return req; + }; + + /** + * Returns the scheme to use for the transport URLs. + * + * @api private + */ + + XHR.prototype.scheme = function () { + return this.socket.options.secure ? 'https' : 'http'; + }; + + /** + * Check if the XHR transports are supported + * + * @param {Boolean} xdomain Check if we support cross domain requests. + * @returns {Boolean} + * @api public + */ + + XHR.check = function (socket, xdomain) { + try { + var request = io.util.request(xdomain), + usesXDomReq = (global.XDomainRequest && request instanceof XDomainRequest), + socketProtocol = (socket && socket.options && socket.options.secure ? 'https:' : 'http:'), + isXProtocol = (global.location && socketProtocol != global.location.protocol); + if (request && !(usesXDomReq && isXProtocol)) { + return true; + } + } catch(e) {} + + return false; + }; + + /** + * Check if the XHR transport supports cross domain requests. + * + * @returns {Boolean} + * @api public + */ + + XHR.xdomainCheck = function (socket) { + return XHR.check(socket, true); + }; + +})( + 'undefined' != typeof io ? io.Transport : module.exports + , 'undefined' != typeof io ? io : module.parent.exports + , this +); +/** + * socket.io + * Copyright(c) 2011 LearnBoost + * MIT Licensed + */ + +(function (exports, io) { + + /** + * Expose constructor. + */ + + exports.htmlfile = HTMLFile; + + /** + * The HTMLFile transport creates a `forever iframe` based transport + * for Internet Explorer. Regular forever iframe implementations will + * continuously trigger the browsers buzy indicators. If the forever iframe + * is created inside a `htmlfile` these indicators will not be trigged. + * + * @constructor + * @extends {io.Transport.XHR} + * @api public + */ + + function HTMLFile (socket) { + io.Transport.XHR.apply(this, arguments); + }; + + /** + * Inherits from XHR transport. + */ + + io.util.inherit(HTMLFile, io.Transport.XHR); + + /** + * Transport name + * + * @api public + */ + + HTMLFile.prototype.name = 'htmlfile'; + + /** + * Creates a new Ac...eX `htmlfile` with a forever loading iframe + * that can be used to listen to messages. Inside the generated + * `htmlfile` a reference will be made to the HTMLFile transport. + * + * @api private + */ + + HTMLFile.prototype.get = function () { + this.doc = new window[(['Active'].concat('Object').join('X'))]('htmlfile'); + this.doc.open(); + this.doc.write(''); + this.doc.close(); + this.doc.parentWindow.s = this; + + var iframeC = this.doc.createElement('div'); + iframeC.className = 'socketio'; + + this.doc.body.appendChild(iframeC); + this.iframe = this.doc.createElement('iframe'); + + iframeC.appendChild(this.iframe); + + var self = this + , query = io.util.query(this.socket.options.query, 't='+ +new Date); + + this.iframe.src = this.prepareUrl() + query; + + io.util.on(window, 'unload', function () { + self.destroy(); + }); + }; + + /** + * The Socket.IO server will write script tags inside the forever + * iframe, this function will be used as callback for the incoming + * information. + * + * @param {String} data The message + * @param {document} doc Reference to the context + * @api private + */ + + HTMLFile.prototype._ = function (data, doc) { + // unescape all forward slashes. see GH-1251 + data = data.replace(/\\\//g, '/'); + this.onData(data); + try { + var script = doc.getElementsByTagName('script')[0]; + script.parentNode.removeChild(script); + } catch (e) { } + }; + + /** + * Destroy the established connection, iframe and `htmlfile`. + * And calls the `CollectGarbage` function of Internet Explorer + * to release the memory. + * + * @api private + */ + + HTMLFile.prototype.destroy = function () { + if (this.iframe){ + try { + this.iframe.src = 'about:blank'; + } catch(e){} + + this.doc = null; + this.iframe.parentNode.removeChild(this.iframe); + this.iframe = null; + + CollectGarbage(); + } + }; + + /** + * Disconnects the established connection. + * + * @returns {Transport} Chaining. + * @api public + */ + + HTMLFile.prototype.close = function () { + this.destroy(); + return io.Transport.XHR.prototype.close.call(this); + }; + + /** + * Checks if the browser supports this transport. The browser + * must have an `Ac...eXObject` implementation. + * + * @return {Boolean} + * @api public + */ + + HTMLFile.check = function (socket) { + if (typeof window != "undefined" && (['Active'].concat('Object').join('X')) in window){ + try { + var a = new window[(['Active'].concat('Object').join('X'))]('htmlfile'); + return a && io.Transport.XHR.check(socket); + } catch(e){} + } + return false; + }; + + /** + * Check if cross domain requests are supported. + * + * @returns {Boolean} + * @api public + */ + + HTMLFile.xdomainCheck = function () { + // we can probably do handling for sub-domains, we should + // test that it's cross domain but a subdomain here + return false; + }; + + /** + * Add the transport to your public io.transports array. + * + * @api private + */ + + io.transports.push('htmlfile'); + +})( + 'undefined' != typeof io ? io.Transport : module.exports + , 'undefined' != typeof io ? io : module.parent.exports +); + +/** + * socket.io + * Copyright(c) 2011 LearnBoost + * MIT Licensed + */ + +(function (exports, io, global) { + + /** + * Expose constructor. + */ + + exports['xhr-polling'] = XHRPolling; + + /** + * The XHR-polling transport uses long polling XHR requests to create a + * "persistent" connection with the server. + * + * @constructor + * @api public + */ + + function XHRPolling () { + io.Transport.XHR.apply(this, arguments); + }; + + /** + * Inherits from XHR transport. + */ + + io.util.inherit(XHRPolling, io.Transport.XHR); + + /** + * Merge the properties from XHR transport + */ + + io.util.merge(XHRPolling, io.Transport.XHR); + + /** + * Transport name + * + * @api public + */ + + XHRPolling.prototype.name = 'xhr-polling'; + + /** + * Indicates whether heartbeats is enabled for this transport + * + * @api private + */ + + XHRPolling.prototype.heartbeats = function () { + return false; + }; + + /** + * Establish a connection, for iPhone and Android this will be done once the page + * is loaded. + * + * @returns {Transport} Chaining. + * @api public + */ + + XHRPolling.prototype.open = function () { + var self = this; + + io.Transport.XHR.prototype.open.call(self); + return false; + }; + + /** + * Starts a XHR request to wait for incoming messages. + * + * @api private + */ + + function empty () {}; + + XHRPolling.prototype.get = function () { + if (!this.isOpen) return; + + var self = this; + + function stateChange () { + if (this.readyState == 4) { + this.onreadystatechange = empty; + + if (this.status == 200) { + self.onData(this.responseText); + self.get(); + } else { + self.onClose(); + } + } + }; + + function onload () { + this.onload = empty; + this.onerror = empty; + self.retryCounter = 1; + self.onData(this.responseText); + self.get(); + }; + + function onerror () { + self.retryCounter ++; + if(!self.retryCounter || self.retryCounter > 3) { + self.onClose(); + } else { + self.get(); + } + }; + + this.xhr = this.request(); + + if (global.XDomainRequest && this.xhr instanceof XDomainRequest) { + this.xhr.onload = onload; + this.xhr.onerror = onerror; + } else { + this.xhr.onreadystatechange = stateChange; + } + + this.xhr.send(null); + }; + + /** + * Handle the unclean close behavior. + * + * @api private + */ + + XHRPolling.prototype.onClose = function () { + io.Transport.XHR.prototype.onClose.call(this); + + if (this.xhr) { + this.xhr.onreadystatechange = this.xhr.onload = this.xhr.onerror = empty; + try { + this.xhr.abort(); + } catch(e){} + this.xhr = null; + } + }; + + /** + * Webkit based browsers show a infinit spinner when you start a XHR request + * before the browsers onload event is called so we need to defer opening of + * the transport until the onload event is called. Wrapping the cb in our + * defer method solve this. + * + * @param {Socket} socket The socket instance that needs a transport + * @param {Function} fn The callback + * @api private + */ + + XHRPolling.prototype.ready = function (socket, fn) { + var self = this; + + io.util.defer(function () { + fn.call(self); + }); + }; + + /** + * Add the transport to your public io.transports array. + * + * @api private + */ + + io.transports.push('xhr-polling'); + +})( + 'undefined' != typeof io ? io.Transport : module.exports + , 'undefined' != typeof io ? io : module.parent.exports + , this +); + +/** + * socket.io + * Copyright(c) 2011 LearnBoost + * MIT Licensed + */ + +(function (exports, io, global) { + /** + * There is a way to hide the loading indicator in Firefox. If you create and + * remove a iframe it will stop showing the current loading indicator. + * Unfortunately we can't feature detect that and UA sniffing is evil. + * + * @api private + */ + + var indicator = global.document && "MozAppearance" in + global.document.documentElement.style; + + /** + * Expose constructor. + */ + + exports['jsonp-polling'] = JSONPPolling; + + /** + * The JSONP transport creates an persistent connection by dynamically + * inserting a script tag in the page. This script tag will receive the + * information of the Socket.IO server. When new information is received + * it creates a new script tag for the new data stream. + * + * @constructor + * @extends {io.Transport.xhr-polling} + * @api public + */ + + function JSONPPolling (socket) { + io.Transport['xhr-polling'].apply(this, arguments); + + this.index = io.j.length; + + var self = this; + + io.j.push(function (msg) { + self._(msg); + }); + }; + + /** + * Inherits from XHR polling transport. + */ + + io.util.inherit(JSONPPolling, io.Transport['xhr-polling']); + + /** + * Transport name + * + * @api public + */ + + JSONPPolling.prototype.name = 'jsonp-polling'; + + /** + * Posts a encoded message to the Socket.IO server using an iframe. + * The iframe is used because script tags can create POST based requests. + * The iframe is positioned outside of the view so the user does not + * notice it's existence. + * + * @param {String} data A encoded message. + * @api private + */ + + JSONPPolling.prototype.post = function (data) { + var self = this + , query = io.util.query( + this.socket.options.query + , 't='+ (+new Date) + '&i=' + this.index + ); + + if (!this.form) { + var form = document.createElement('form') + , area = document.createElement('textarea') + , id = this.iframeId = 'socketio_iframe_' + this.index + , iframe; + + form.className = 'socketio'; + form.style.position = 'absolute'; + form.style.top = '0px'; + form.style.left = '0px'; + form.style.display = 'none'; + form.target = id; + form.method = 'POST'; + form.setAttribute('accept-charset', 'utf-8'); + area.name = 'd'; + form.appendChild(area); + document.body.appendChild(form); + + this.form = form; + this.area = area; + } + + this.form.action = this.prepareUrl() + query; + + function complete () { + initIframe(); + self.socket.setBuffer(false); + }; + + function initIframe () { + if (self.iframe) { + self.form.removeChild(self.iframe); + } + + try { + // ie6 dynamic iframes with target="" support (thanks Chris Lambacher) + iframe = document.createElement('
- +
<%= render :partial => 'shared/cheatsheet' %> -
- - <% if authenticated? %> +
+ + <% if current_user %>

SHARE INVITE

- -
+ +

The Metamaps platform is currently in an invite-only beta with the express purpose of creating a high value knowledge ecosystem, a diverse community of contributors and a culture of collaboration and curiosity.

As a valued beta tester, you have the ability to invite your peers, colleagues and collaborators onto the platform.

Below is a personal invite link containing your unique access code, which can be used multiple times.

-

<%= @invite_link %> +

<%= determine_invite_link %>

- +
- + <% # this is the create new map form %> -
+
<%= render :partial => 'layouts/newmap' %>
- -
+ +
<%= render :partial => 'shared/forkmap' %>
@@ -254,9 +249,8 @@ <%= render :partial => 'shared/switchmetacodes' %>
<% end %> - +
- diff --git a/app/views/layouts/_upperelements.html.erb b/app/views/layouts/_upperelements.html.erb index adb1a65e..91a9955b 100644 --- a/app/views/layouts/_upperelements.html.erb +++ b/app/views/layouts/_upperelements.html.erb @@ -4,7 +4,7 @@
@@ -20,28 +20,6 @@
SUPPORT US!
- <% if authenticated? %> - -
-
Junto
-
-

REALTIME

- OFF - ON -
-
-
    -
  • - <%= image_tag user.image.url(:thirtytwo), :size => "24x24", :class => "rtUserImage" %> - <%= user.name %> (me) -
    -
  • -
-
-
-
- <% end %> -
Filter
@@ -50,7 +28,7 @@
- <% if authenticated? %> + <% if current_user %>
Save To New Map
@@ -58,9 +36,9 @@ <% end %>
-
+
- <% if authenticated? %> + <% if current_user %>
Create New Map
@@ -70,9 +48,9 @@ <% if !(controller_name == "sessions" && action_name == "new") %>
Account
- <% if user && user.image %> - <%= image_tag user.image.url(:thirtytwo), :size => "32x32" %> - <% elsif !authenticated? %> + <% if current_user && current_user.image %> + <%= image_tag current_user.image.url(:thirtytwo), :size => "32x32" %> + <% elsif !current_user %> SIGN IN
<% end %> @@ -83,4 +61,4 @@
<% end %>
-
\ No newline at end of file +
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index e21a91c3..2b8aa96e 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,152 +1,57 @@ -<%# -# @file -# Main application file. Holds scaffolding present on every page. -# Then a certain non-partial view (no _ preceding filename) will be -# displayed within, based on URL -#%> - - - - - <%=h yield(:title) %> - <%= csrf_meta_tags %> - - - <%= stylesheet_link_tag "application", :media => "all" %> - <%= javascript_include_tag "application" %> - - - - - - - - - - - -"> - - <% if devise_error_messages? %> -

<%= devise_error_messages! %>

- <% elsif notice %> -

<%= notice %>

- <% end %> - - <%= content_tag :div, class: "main" do %> - - <% classes = action_name == "home" ? "homePage" : "" - classes += action_name == "home" && authenticated? ? " explorePage" : "" - classes += controller_name == "maps" && action_name == "index" ? " explorePage" : "" - if controller_name == "maps" && action_name == "show" - classes += " mapPage" - if @map.authorize_to_edit(current_user) - classes += " canEditMap" - end - if @map.permission == "commons" - classes += " commonsMap" - end - end - classes += controller_name == "topics" && action_name == "show" ? " topicPage" : "" - %> - -
- - <%= render :partial => 'layouts/upperelements' %> - - <%= yield %> - -
- <% if authenticated? %> - <% # for creating and pulling in topics and synapses %> - <%= render :partial => 'maps/newtopic' %> - <%= render :partial => 'maps/newsynapse' %> - <% # for populating the change metacode list on the topic card %> - <%= render :partial => 'shared/metacodeoptions' %> - <% end %> - <%= render :partial => 'layouts/lowermapelements' %> - -
-
-
- - <% end %> - -<%= render :partial => 'layouts/lightboxes' %> -<%= render :partial => 'layouts/templates' %> -<%= render :partial => 'shared/metacodeBgColors' %> - - -<%= render :partial => 'layouts/googleanalytics' if Rails.env.production? %> - - +<%# +# @file +# Main application file. Holds scaffolding present on every page. +# Then a certain non-partial view (no _ preceding filename) will be +# displayed within, based on URL +#%> + +<%= render :partial => 'layouts/head' %> + +"> + + <% if devise_error_messages? %> +

<%= devise_error_messages! %>

+ <% elsif notice %> +

<%= notice %>

+ <% end %> + + <%= content_tag :div, class: "main" do %> + + <% classes = action_name == "home" ? "homePage" : "" + classes += action_name == "home" && authenticated? ? " explorePage" : "" + classes += controller_name == "maps" && action_name == "index" ? " explorePage" : "" + if controller_name == "maps" && action_name == "show" + classes += " mapPage" + if policy(@map).update? + classes += " canEditMap" + end + if @map.permission == "commons" + classes += " commonsMap" + end + end + classes += controller_name == "topics" && action_name == "show" ? " topicPage" : "" + %> + +
+ + <%= render :partial => 'layouts/upperelements', :locals => { :appsPage => false } %> + + <%= yield %> + +
+ <% if authenticated? %> + <% # for creating and pulling in topics and synapses %> + <%= render :partial => 'maps/newtopic' %> + <%= render :partial => 'maps/newsynapse' %> + <% # for populating the change metacode list on the topic card %> + <%= render :partial => 'shared/metacodeoptions' %> + <% end %> + <%= render :partial => 'layouts/lowermapelements' %> + +
+
+
+ + <% end %> + +<%= render :partial => 'layouts/foot' %> diff --git a/app/views/layouts/doorkeeper.html.erb b/app/views/layouts/doorkeeper.html.erb new file mode 100644 index 00000000..f6ae4e91 --- /dev/null +++ b/app/views/layouts/doorkeeper.html.erb @@ -0,0 +1,42 @@ +<%# +# @file +# Main application file. Holds scaffolding present on every page. +# Then a certain non-partial view (no _ preceding filename) will be +# displayed within, based on URL +#%> + +<%= render :partial => 'layouts/head' %> + + + + <% if devise_error_messages? %> +

<%= devise_error_messages! %>

+ <% elsif notice %> +

<%= notice %>

+ <% end %> + + <%= content_tag :div, class: "main" do %> + +
+ + <%= render :partial => 'layouts/upperelements', :locals => {:appsPage => true } %> + + <%= yield %> + +
+ <% if current_user %> + <% # for creating and pulling in topics and synapses %> + <%= render :partial => 'maps/newtopic' %> + <%= render :partial => 'maps/newsynapse' %> + <% # for populating the change metacode list on the topic card %> + <%= render :partial => 'shared/metacodeoptions' %> + <% end %> + <%= render :partial => 'layouts/lowermapelements' %> + +
+
+
+ + <% end %> + +<%= render :partial => 'layouts/foot' %> diff --git a/app/views/main/home.html.erb b/app/views/main/home.html.erb index 3c1319e0..6089b7df 100644 --- a/app/views/main/home.html.erb +++ b/app/views/main/home.html.erb @@ -2,52 +2,42 @@ # @file # Located at / # Shows 3 most recently created topics, synapses, and maps. - #%> - -<% if !authenticated? %> - <% content_for :title, "Home | Metamaps" %> -
-
-
Make Sense with Metamaps
-
- METAMAPS.CC is a free and open source platform that supports real-time sense-making, distributed collaboration, and the creative intelligence of individuals, organizations and communities. We are currently in an invite-only beta. -
-
-
-
- -
-

Who finds it useful?

-

Designers, inventors, artists, educators, strategists, consultants, facilitators, entrepreneurs, systems thinkers, changemakers, analysts, students, researchers... maybe you!

- - EXPLORE FEATURED MAPS - REQUEST INVITE -
-
-
-
-
-
- <% # our partners %> -
-
-
- - -<% elsif authenticated? %> - <% content_for :title, "Explore Active Maps | Metamaps" %> - -<% end %> + # +%> + +<% content_for :title, "Home | Metamaps" %> +
+
+
Make Sense with Metamaps
+
+ METAMAPS.CC is a free and open source platform that supports real-time sense-making, distributed collaboration, and the creative intelligence of individuals, organizations and communities. We are currently in an invite-only beta. +
+
+
+
+ +
+

Who finds it useful?

+

Designers, inventors, artists, educators, strategists, consultants, facilitators, entrepreneurs, systems thinkers, changemakers, analysts, students, researchers... maybe you!

+ + EXPLORE FEATURED MAPS + REQUEST INVITE +
+
+
+
+
+
+ <% # our partners %> +
+
+
+ + diff --git a/app/views/maps/_mapinfobox.html.erb b/app/views/maps/_mapinfobox.html.erb index 1fa6da02..ff90532c 100644 --- a/app/views/maps/_mapinfobox.html.erb +++ b/app/views/maps/_mapinfobox.html.erb @@ -4,7 +4,7 @@ #%>
- <%= @map && @map.authorize_to_edit(user) ? " canEdit" : "" %> + <%= @map && policy(@map).update? ? " canEdit" : "" %> <%= @map && @map.permission != 'private' ? " shareable" : "" %>"> <% if @map %> @@ -41,7 +41,7 @@
- <% if (authenticated? && @map.authorize_to_edit(user)) || (!authenticated? && @map.desc != "" && @map.desc != nil )%> + <% if (authenticated? && policy(@map).update?) || (!authenticated? && @map.desc != "" && @map.desc != nil )%> <%= best_in_place @map, :desc, :activator => "#mapInfoDesc", :as => :textarea, :placeholder => "Click to add description...", :class => 'best_in_place_desc' %> <% end %>
diff --git a/app/views/maps/_newsynapse.html.erb b/app/views/maps/_newsynapse.html.erb index 1c190324..00066933 100644 --- a/app/views/maps/_newsynapse.html.erb +++ b/app/views/maps/_newsynapse.html.erb @@ -1,3 +1,3 @@ -<%= form_for Synapse.new, url: synapses_url, remote: true do |form| %> -<%= form.text_field :desc, :placeholder => "describe the connection..." %> -<% end %> +<%= form_for Synapse.new, url: synapses_url, remote: true do |form| %> +<%= form.text_field :desc, :placeholder => "describe the connection..." %> +<% end %> diff --git a/app/views/maps/activemaps.html.erb b/app/views/maps/activemaps.html.erb new file mode 100644 index 00000000..cfcbce27 --- /dev/null +++ b/app/views/maps/activemaps.html.erb @@ -0,0 +1,15 @@ +<% # + # @file + # Shows a list of recently active maps + # GET /explore/active(.:format) + # %> + + diff --git a/app/views/maps/export.xls.erb b/app/views/maps/export.xls.erb new file mode 100644 index 00000000..7030d501 --- /dev/null +++ b/app/views/maps/export.xls.erb @@ -0,0 +1,9 @@ + + <% @spreadsheet.each do |line| %> + + <% line.each do |field| %> + + <% end %> + + <% end %> +
<%= field %>
diff --git a/app/views/maps/featuredmaps.html.erb b/app/views/maps/featuredmaps.html.erb new file mode 100644 index 00000000..2c438b49 --- /dev/null +++ b/app/views/maps/featuredmaps.html.erb @@ -0,0 +1,15 @@ +<% # + # @file + # Shows a list of featured maps + # GET /explore/featured(.:format) + # %> + + diff --git a/app/views/maps/index.html.erb b/app/views/maps/index.html.erb deleted file mode 100644 index cd9661e0..00000000 --- a/app/views/maps/index.html.erb +++ /dev/null @@ -1,36 +0,0 @@ -<%# - # @file - # Shows a list of all maps, or just a user's maps. - # TODO: What url is this accessible at? - #%> - - - - diff --git a/app/views/maps/mymaps.html.erb b/app/views/maps/mymaps.html.erb new file mode 100644 index 00000000..60c69f68 --- /dev/null +++ b/app/views/maps/mymaps.html.erb @@ -0,0 +1,15 @@ +<% # + # @file + # Shows a list of current user's maps + # GET /explore/mine(.:format) + # %> + + diff --git a/app/views/maps/show.html.erb b/app/views/maps/show.html.erb index 0c5e4f97..5d6859b7 100644 --- a/app/views/maps/show.html.erb +++ b/app/views/maps/show.html.erb @@ -13,5 +13,6 @@ Metamaps.Topics = <%= @alltopics.to_json.html_safe %>; Metamaps.Synapses = <%= @allsynapses.to_json.html_safe %>; Metamaps.Mappings = <%= @allmappings.to_json.html_safe %>; + Metamaps.Messages = <%= @allmessages.to_json.html_safe %>; Metamaps.Visualize.type = "ForceDirected"; diff --git a/app/views/maps/usermaps.html.erb b/app/views/maps/usermaps.html.erb new file mode 100644 index 00000000..c0855329 --- /dev/null +++ b/app/views/maps/usermaps.html.erb @@ -0,0 +1,18 @@ +<% # + # @file + # Shows a list of a user's maps + # GET /explore/mapper/:id(.:format) + # %> + + diff --git a/app/views/metacodes/_form.html.erb b/app/views/metacodes/_form.html.erb index ee19048b..6e727ad5 100644 --- a/app/views/metacodes/_form.html.erb +++ b/app/views/metacodes/_form.html.erb @@ -2,7 +2,6 @@ <% if @metacode.errors.any? %>

<%= pluralize(@metacode.errors.count, "error") %> prohibited this metacode from being saved:

-
    <% @metacode.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • @@ -16,9 +15,20 @@ <%= f.text_field :name %>
+ <% unless @metacode.new_record? %> +
+ <%= f.label 'Current Icon' %> + <%= image_tag @metacode.icon, width: 96 %> +
+ <% end %>
- <%= f.label :icon %> - <%= f.text_field :icon %> + <% if @metacode.new_record? %> + <%= f.label 'Icon' %> + <% else %> + <%= f.label 'Replace Icon: ' %> + <% end %> + <%= f.hidden_field :manual_icon, value: nil %> + <%= f.file_field :aws_icon %>
diff --git a/app/views/metacodes/index.html.erb b/app/views/metacodes/index.html.erb index 4f3563f1..d60ffb8c 100644 --- a/app/views/metacodes/index.html.erb +++ b/app/views/metacodes/index.html.erb @@ -14,7 +14,7 @@ <% @metacodes.each do |metacode| %> <%= metacode.name %> - <%= asset_path metacode.icon %> + <%= metacode.icon %> <% if metacode.color %> <%= metacode.color %> @@ -22,7 +22,7 @@ <% else %> <% end %> - + <%= image_tag metacode.icon, width: 40 %> <%= link_to 'Edit', edit_metacode_path(metacode), :data => { :bypass => 'true'} %> <% end %> diff --git a/app/views/shared/_cheatsheet.html.erb b/app/views/shared/_cheatsheet.html.erb index 1f57e6f9..c02246ac 100644 --- a/app/views/shared/_cheatsheet.html.erb +++ b/app/views/shared/_cheatsheet.html.erb @@ -76,7 +76,8 @@
Open 'Create Synapse' prompt: Right-click & drag from one topic to another
-
Enter: Create synapse
+
Enter or Tab: Create synapse
+
Esc or Delete: Cancel synapse creation
*You do not have to add a description
Create new Topic with Synapse: Right-click + drag from topic to open canvas
Enter: Create topic
diff --git a/app/views/synapses/_new.html.erb b/app/views/synapses/_new.html.erb deleted file mode 100644 index e1e7d176..00000000 --- a/app/views/synapses/_new.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<%# - # @file - # Partial for rendered a new synapse form - # TODO: Is this used? Where? - #%> -
- <%= form_for Synapse.new, url: synapses_url, remote: true do |form| %> - <%= form.text_field :desc, :placeholder => "describe the connection..." %> - <% end %> -
diff --git a/bin/delayed_job b/bin/delayed_job new file mode 100755 index 00000000..edf19598 --- /dev/null +++ b/bin/delayed_job @@ -0,0 +1,5 @@ +#!/usr/bin/env ruby + +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'config', 'environment')) +require 'delayed/command' +Delayed::Command.new(ARGV).daemonize diff --git a/bin/jenkins-test.sh b/bin/jenkins-test.sh index 313c23b3..0afd3f66 100755 --- a/bin/jenkins-test.sh +++ b/bin/jenkins-test.sh @@ -1,7 +1,8 @@ #!/bin/bash -l -#prerequisites -#sudo aptitude -q -y install libpq-dev +# jenkins machine prerequisites +# sudo aptitude -q -y install libpq-dev +# install rvm with user gemsets source "$HOME/.rvm/scripts/rvm" rvm use $(cat .ruby-version) || \ @@ -20,5 +21,8 @@ sed -i -e "s/DB_USERNAME='.*'/DB_USERNAME='jenkins'/" .env #test bundle install -rake db:create db:test:prepare -bundle exec rspec +rake db:drop +rake db:create +rake db:schema:load +rake db:migrate +COVERAGE=on bundle exec rspec diff --git a/config/application.rb b/config/application.rb index 399b32c9..0431e58b 100644 --- a/config/application.rb +++ b/config/application.rb @@ -1,5 +1,6 @@ require File.expand_path('../boot', __FILE__) +require 'csv' require 'rails/all' require 'dotenv' @@ -10,6 +11,7 @@ Dotenv.load ".env.#{ENV["RAILS_ENV"]}", '.env' module Metamaps class Application < Rails::Application + config.active_job.queue_adapter = :delayed_job # Settings in config/environments/* take precedence over those specified here. # Application configuration should go into files in config/initializers # -- all .rb files in that directory are automatically loaded. @@ -35,6 +37,13 @@ module Metamaps # Configure the default encoding used in templates for Ruby 1.9. config.encoding = "utf-8" + config.to_prepare do + Doorkeeper::ApplicationsController.layout "doorkeeper" + Doorkeeper::AuthorizationsController.layout "doorkeeper" + Doorkeeper::AuthorizedApplicationsController.layout "doorkeeper" + Doorkeeper::ApplicationController.helper ApplicationHelper + end + # Configure sensitive parameters which will be filtered from the log file. config.filter_parameters += [:password] @@ -53,5 +62,8 @@ module Metamaps g.test_framework :rspec end config.active_record.raise_in_transactional_callbacks = true + + # pundit errors return 403 FORBIDDEN + config.action_dispatch.rescue_responses["Pundit::NotAuthorizedError"] = :forbidden end end diff --git a/config/database.yml b/config/database.yml index 469ede34..c6cad0fa 100644 --- a/config/database.yml +++ b/config/database.yml @@ -1,21 +1,21 @@ -default: &default - min_messages: WARNING - encoding: unicode - pool: 5 - adapter: postgresql - host: <%= ENV['DB_HOST'] %> - port: <%= ENV['DB_PORT'] %> - username: <%= ENV['DB_USERNAME'] %> - password: <%= ENV['DB_PASSWORD'] %> - -development: - <<: *default - database: <%= ENV['DB_NAME'] %>_development - -test: - <<: *default - database: <%= ENV['DB_NAME'] %>_test - -production: - <<: *default - database: <%= ENV['DB_NAME'] %>_production +default: &default + min_messages: WARNING + encoding: unicode + pool: 5 + adapter: postgresql + host: <%= ENV['DB_HOST'] %> + port: <%= ENV['DB_PORT'] %> + username: <%= ENV['DB_USERNAME'] %> + password: <%= ENV['DB_PASSWORD'] %> + +development: + <<: *default + database: <%= ENV['DB_NAME'] %>_development + +test: + <<: *default + database: <%= ENV['DB_NAME'] %>_test + +production: + <<: *default + database: <%= ENV['DB_NAME'] %>_production diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb new file mode 100644 index 00000000..843fe831 --- /dev/null +++ b/config/initializers/doorkeeper.rb @@ -0,0 +1,102 @@ +Doorkeeper.configure do + # Change the ORM that doorkeeper will use (needs plugins) + orm :active_record + + # This block will be called to check whether the resource owner is authenticated or not. + resource_owner_authenticator do + current_user || redirect_to(new_user_session_url) + end + + # If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. + admin_authenticator do + current_user || redirect_to(new_user_session_url) + end + + # Authorization Code expiration time (default 10 minutes). + # authorization_code_expires_in 10.minutes + + # Access token expiration time (default 2 hours). + # If you want to disable expiration, set this to nil. + access_token_expires_in nil + + # Assign a custom TTL for implicit grants. + # custom_access_token_expires_in do |oauth_client| + # oauth_client.application.additional_settings.implicit_oauth_expiration + # end + + # Use a custom class for generating the access token. + # https://github.com/doorkeeper-gem/doorkeeper#custom-access-token-generator + # access_token_generator "::Doorkeeper::JWT" + + # Reuse access token for the same resource owner within an application (disabled by default) + # Rationale: https://github.com/doorkeeper-gem/doorkeeper/issues/383 + # reuse_access_token + + # Issue access tokens with refresh token (disabled by default) + # use_refresh_token + + # Provide support for an owner to be assigned to each registered application (disabled by default) + # Optional parameter :confirmation => true (default false) if you want to enforce ownership of + # a registered application + # Note: you must also run the rails g doorkeeper:application_owner generator to provide the necessary support + # enable_application_owner :confirmation => false + + # Define access token scopes for your provider + # For more information go to + # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes + # default_scopes :public + # optional_scopes :write, :update + + # Change the way client credentials are retrieved from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:client_id` and `:client_secret` params from the `params` object. + # Check out the wiki for more information on customization + # client_credentials :from_basic, :from_params + + # Change the way access token is authenticated from the request object. + # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then + # falls back to the `:access_token` or `:bearer_token` params from the `params` object. + # Check out the wiki for more information on customization + # access_token_methods :from_bearer_authorization, :from_access_token_param, :from_bearer_param + + # Change the native redirect uri for client apps + # When clients register with the following redirect uri, they won't be redirected to any server and the authorization code will be displayed within the provider + # The value can be any string. Use nil to disable this feature. When disabled, clients must provide a valid URL + # (Similar behaviour: https://developers.google.com/accounts/docs/OAuth2InstalledApp#choosingredirecturi) + # + # native_redirect_uri 'urn:ietf:wg:oauth:2.0:oob' + + # Forces the usage of the HTTPS protocol in non-native redirect uris (enabled + # by default in non-development environments). OAuth2 delegates security in + # communication to the HTTPS protocol so it is wise to keep this enabled. + # + # force_ssl_in_redirect_uri !Rails.env.development? + + # Specify what grant flows are enabled in array of Strings. The valid + # strings and the flows they enable are: + # + # "authorization_code" => Authorization Code Grant Flow + # "implicit" => Implicit Grant Flow + # "password" => Resource Owner Password Credentials Grant Flow + # "client_credentials" => Client Credentials Grant Flow + # + # If not specified, Doorkeeper enables authorization_code and + # client_credentials. + # + # implicit and password grant flows have risks that you should understand + # before enabling: + # http://tools.ietf.org/html/rfc6819#section-4.4.2 + # http://tools.ietf.org/html/rfc6819#section-4.4.3 + # + # grant_flows %w(authorization_code client_credentials) + + # Under some circumstances you might want to have applications auto-approved, + # so that the user skips the authorization step. + # For example if dealing with a trusted application. + # skip_authorization do |resource_owner, client| + # client.superapp? or resource_owner.admin? + # end + + # WWW-Authenticate Realm (default "Doorkeeper"). + # realm "Doorkeeper" +end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index 72aca7e4..8b5e5425 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -3,3 +3,5 @@ # Add new mime types for use in respond_to blocks: # Mime::Type.register "text/richtext", :rtf # Mime::Type.register_alias "text/html", :iphone + +Mime::Type.register "application/xls", :xls diff --git a/config/initializers/paperclip.rb b/config/initializers/paperclip.rb index 1447988b..07d6e2f3 100644 --- a/config/initializers/paperclip.rb +++ b/config/initializers/paperclip.rb @@ -1,2 +1,2 @@ -Paperclip::Attachment.default_options[:url] = ':s3_domain_url' +Paperclip::Attachment.default_options[:url] = ':s3_domain_url' Paperclip::Attachment.default_options[:path] = '/:class/:attachment/:id_partition/:style/:filename' \ No newline at end of file diff --git a/config/initializers/uservoice.rb b/config/initializers/uservoice.rb index 3a303174..293b908b 100644 --- a/config/initializers/uservoice.rb +++ b/config/initializers/uservoice.rb @@ -1,7 +1,7 @@ -require 'uservoice-ruby' - -def current_sso_token - @current_sso_token ||= UserVoice.generate_sso_token('metamapscc', ENV['SSO_KEY'], { - :email => current_user.email - }, 300) # Default expiry time is 5 minutes = 300 seconds +require 'uservoice-ruby' + +def current_sso_token + @current_sso_token ||= UserVoice.generate_sso_token('metamapscc', ENV['SSO_KEY'], { + :email => current_user.email + }, 300) # Default expiry time is 5 minutes = 300 seconds end \ No newline at end of file diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml new file mode 100644 index 00000000..ad83dc78 --- /dev/null +++ b/config/locales/doorkeeper.en.yml @@ -0,0 +1,122 @@ +en: + activerecord: + attributes: + doorkeeper/application: + name: 'Name' + redirect_uri: 'Redirect URIs' + errors: + models: + doorkeeper/application: + attributes: + redirect_uri: + fragment_present: 'cannot contain a fragment.' + invalid_uri: 'must be a valid URI.' + relative_uri: 'must be an absolute URI.' + secured_uri: 'must be an HTTPS/SSL URI.' + + doorkeeper: + applications: + confirmations: + destroy: 'Are you sure?' + buttons: + back: 'Back' + edit: 'Edit' + destroy: 'remove' + submit: 'Submit' + cancel: 'Cancel' + authorize: 'Authorize' + form: + error: 'Whoops! Check your form for possible errors' + help: + redirect_uri: 'Use one line per URI' + native_redirect_uri: 'Use %{native_redirect_uri} for local tests' + scopes: 'Separate scopes with spaces. Leave blank to use the default scopes.' + edit: + title: 'Edit application' + index: + title: 'Registered Apps' + new: 'New App' + name: 'Name' + callback_url: 'Redirect URIs' + new: + title: 'New App' + show: + title: '%{name}' + application_id: 'App ID' + secret: 'App Secret' + callback_urls: 'Redirect URIs' + + authorizations: + buttons: + authorize: 'Authorize' + deny: 'Deny' + error: + title: 'Invalid Authorization Request' + new: + title: 'Authorization required' + prompt: 'Authorize %{client_name} to use your account?' + able_to: 'This application will be able to' + show: + title: 'Authorization code' + + authorized_applications: + confirmations: + revoke: 'Are you sure?' + buttons: + revoke: 'Revoke' + index: + title: 'Authorized Apps' + application: 'App' + created_at: 'Date Authorized' + date_format: '%Y-%m-%d' + + errors: + messages: + # Common error messages + invalid_request: 'The request is missing a required parameter, includes an unsupported parameter value, or is otherwise malformed.' + invalid_redirect_uri: 'The redirect uri included is not valid.' + unauthorized_client: 'The client is not authorized to perform this request using this method.' + access_denied: 'The resource owner or authorization server denied the request.' + invalid_scope: 'The requested scope is invalid, unknown, or malformed.' + server_error: 'The authorization server encountered an unexpected condition which prevented it from fulfilling the request.' + temporarily_unavailable: 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.' + + #configuration error messages + credential_flow_not_configured: 'Resource Owner Password Credentials flow failed due to Doorkeeper.configure.resource_owner_from_credentials being unconfigured.' + resource_owner_authenticator_not_configured: 'Resource Owner find failed due to Doorkeeper.configure.resource_owner_authenticator being unconfiged.' + + # Access grant errors + unsupported_response_type: 'The authorization server does not support this response type.' + + # Access token errors + invalid_client: 'Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method.' + invalid_grant: 'The provided authorization grant is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client.' + unsupported_grant_type: 'The authorization grant type is not supported by the authorization server.' + + # Password Access token errors + invalid_resource_owner: 'The provided resource owner credentials are not valid, or resource owner cannot be found' + + invalid_token: + revoked: "The access token was revoked" + expired: "The access token expired" + unknown: "The access token is invalid" + + flash: + applications: + create: + notice: 'Application created.' + destroy: + notice: 'Application deleted.' + update: + notice: 'Application updated.' + authorized_applications: + destroy: + notice: 'Application revoked.' + + layouts: + admin: + nav: + app: 'METAMAPS' + applications: 'Applications' + application: + title: 'OAuth authorization required' diff --git a/config/routes.rb b/config/routes.rb index a3ab6e3a..a9f82d9c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ Metamaps::Application.routes.draw do + use_doorkeeper root to: 'main#home', via: :get get 'request', to: 'main#requestinvite', as: :request @@ -9,9 +10,23 @@ Metamaps::Application.routes.draw do get 'search/mappers', to: 'main#searchmappers', as: :searchmappers get 'search/synapses', to: 'main#searchsynapses', as: :searchsynapses + namespace :api, path: '/api/v1', defaults: {format: :json} do + resources :maps, only: [:create, :show, :update, :destroy] + resources :synapses, only: [:create, :show, :update, :destroy] + resources :topics, only: [:create, :show, :update, :destroy] + resources :mappings, only: [:create, :show, :update, :destroy] + resources :tokens, only: [ :create, :destroy] do + get :my_tokens, on: :collection + end + end + + resources :messages, only: [:show, :create, :update, :destroy] resources :mappings, except: [:index, :new, :edit] resources :metacode_sets, :except => [:show] - resources :metacodes, :except => [:show, :destroy] + + resources :metacodes, :except => [:destroy] + get 'metacodes/:name', to: 'metacodes#show' + resources :synapses, except: [:index, :new, :edit] resources :topics, except: [:index, :new, :edit] do get :autocomplete_topic, :on => :collection @@ -20,11 +35,14 @@ Metamaps::Application.routes.draw do get 'topics/:id/relative_numbers', to: 'topics#relative_numbers', as: :relative_numbers get 'topics/:id/relatives', to: 'topics#relatives', as: :relatives - get 'explore/active', to: 'maps#index', as: :activemaps - get 'explore/featured', to: 'maps#index', as: :featuredmaps - get 'explore/mine', to: 'maps#index', as: :mymaps - get 'explore/mapper/:id', to: 'maps#index', as: :usermaps - resources :maps, except: [:new, :edit] + resources :maps, except: [:index, :new, :edit] + get 'maps/:id/export', to: 'maps#export' + + get 'explore/active', to: 'maps#activemaps' + get 'explore/featured', to: 'maps#featuredmaps' + get 'explore/mine', to: 'maps#mymaps' + get 'explore/mapper/:id', to: 'maps#usermaps' + get 'maps/:id/contains', to: 'maps#contains', as: :contains post 'maps/:id/upload_screenshot', to: 'maps#screenshot', as: :screenshot diff --git a/db/migrate/20151205205831_messages.rb b/db/migrate/20151205205831_messages.rb new file mode 100644 index 00000000..e892ebcc --- /dev/null +++ b/db/migrate/20151205205831_messages.rb @@ -0,0 +1,15 @@ +class Messages < ActiveRecord::Migration + def change + create_table :messages do |t| + t.text :message + t.references :user + t.integer :resource_id + t.string :resource_type + + t.timestamps + end + add_index :messages, :user_id + add_index :messages, :resource_id + add_index :messages, :resource_type + end +end diff --git a/db/migrate/20160223061711_add_attachment_icon_to_metacodes.rb b/db/migrate/20160223061711_add_attachment_icon_to_metacodes.rb new file mode 100644 index 00000000..82162f9f --- /dev/null +++ b/db/migrate/20160223061711_add_attachment_icon_to_metacodes.rb @@ -0,0 +1,8 @@ +class AddAttachmentIconToMetacodes < ActiveRecord::Migration + def change + change_table :metacodes do |t| + t.rename :icon, :manual_icon + t.attachment :aws_icon + end + end +end diff --git a/db/migrate/20160310200131_create_tokens.rb b/db/migrate/20160310200131_create_tokens.rb new file mode 100644 index 00000000..7b9272a5 --- /dev/null +++ b/db/migrate/20160310200131_create_tokens.rb @@ -0,0 +1,11 @@ +class CreateTokens < ActiveRecord::Migration + def change + create_table :tokens do |t| + t.string :token + t.string :description + t.references :user, index: true, foreign_key: true + + t.timestamps null: false + end + end +end diff --git a/db/migrate/20160312234946_create_events.rb b/db/migrate/20160312234946_create_events.rb new file mode 100644 index 00000000..4ce01e35 --- /dev/null +++ b/db/migrate/20160312234946_create_events.rb @@ -0,0 +1,11 @@ +class CreateEvents < ActiveRecord::Migration + def change + create_table :events do |t| + t.string :kind, limit: 255 + t.references :eventable, polymorphic: true, index: true + t.references :user, index: true + t.references :map, index: true + t.timestamps + end + end +end diff --git a/db/migrate/20160312235006_create_webhooks.rb b/db/migrate/20160312235006_create_webhooks.rb new file mode 100644 index 00000000..0d1cf899 --- /dev/null +++ b/db/migrate/20160312235006_create_webhooks.rb @@ -0,0 +1,10 @@ +class CreateWebhooks < ActiveRecord::Migration + def change + create_table :webhooks do |t| + t.references :hookable, polymorphic: true, index: true + t.string :kind, null: false + t.string :uri, null: false + t.text :event_types, array: true, default: [] + end + end +end diff --git a/db/migrate/20160313003721_create_delayed_jobs.rb b/db/migrate/20160313003721_create_delayed_jobs.rb new file mode 100644 index 00000000..27fdcf6c --- /dev/null +++ b/db/migrate/20160313003721_create_delayed_jobs.rb @@ -0,0 +1,22 @@ +class CreateDelayedJobs < ActiveRecord::Migration + def self.up + create_table :delayed_jobs, force: true do |table| + table.integer :priority, default: 0, null: false # Allows some jobs to jump to the front of the queue + table.integer :attempts, default: 0, null: false # Provides for retries, but still fail eventually. + table.text :handler, null: false # YAML-encoded string of the object that will do work + table.text :last_error # reason for last failure (See Note below) + table.datetime :run_at # When to run. Could be Time.zone.now for immediately, or sometime in the future. + table.datetime :locked_at # Set when a client is working on this object + table.datetime :failed_at # Set when all retries have failed (actually, by default, the record is deleted instead) + table.string :locked_by # Who is working on this object (if locked) + table.string :queue # The name of the queue this job is in + table.timestamps null: true + end + + add_index :delayed_jobs, [:priority, :run_at], name: "delayed_jobs_priority" + end + + def self.down + drop_table :delayed_jobs + end +end diff --git a/db/migrate/20160318141618_create_doorkeeper_tables.rb b/db/migrate/20160318141618_create_doorkeeper_tables.rb new file mode 100644 index 00000000..d89b005c --- /dev/null +++ b/db/migrate/20160318141618_create_doorkeeper_tables.rb @@ -0,0 +1,50 @@ +class CreateDoorkeeperTables < ActiveRecord::Migration + def change + create_table :oauth_applications do |t| + t.string :name, null: false + t.string :uid, null: false + t.string :secret, null: false + t.text :redirect_uri, null: false + t.string :scopes, null: false, default: '' + t.timestamps + end + + add_index :oauth_applications, :uid, unique: true + + create_table :oauth_access_grants do |t| + t.integer :resource_owner_id, null: false + t.integer :application_id, null: false + t.string :token, null: false + t.integer :expires_in, null: false + t.text :redirect_uri, null: false + t.datetime :created_at, null: false + t.datetime :revoked_at + t.string :scopes + end + + add_index :oauth_access_grants, :token, unique: true + + create_table :oauth_access_tokens do |t| + t.integer :resource_owner_id + t.integer :application_id + + # If you use a custom token generator you may need to change this column + # from string to text, so that it accepts tokens larger than 255 + # characters. More info on custom token generators in: + # https://github.com/doorkeeper-gem/doorkeeper/tree/v3.0.0.rc1#custom-access-token-generator + # + # t.text :token, null: false + t.string :token, null: false + + t.string :refresh_token + t.integer :expires_in + t.datetime :revoked_at + t.datetime :created_at, null: false + t.string :scopes + end + + add_index :oauth_access_tokens, :token, unique: true + add_index :oauth_access_tokens, :resource_owner_id + add_index :oauth_access_tokens, :refresh_token, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 9ecb3488..1ad745a2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,11 +11,44 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160120061513) do +ActiveRecord::Schema.define(version: 20160318141618) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" + create_table "delayed_jobs", force: :cascade do |t| + t.integer "priority", default: 0, null: false + t.integer "attempts", default: 0, null: false + t.text "handler", null: false + t.text "last_error" + t.datetime "run_at" + t.datetime "locked_at" + t.datetime "failed_at" + t.string "locked_by" + t.string "queue" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "delayed_jobs", ["priority", "run_at"], name: "delayed_jobs_priority", using: :btree + + create_table "events", force: :cascade do |t| + t.string "kind", limit: 255 + t.integer "eventable_id" + t.string "eventable_type" + t.integer "user_id" + t.integer "map_id" + t.integer "sequence_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "events", ["eventable_type", "eventable_id"], name: "index_events_on_eventable_type_and_eventable_id", using: :btree + add_index "events", ["map_id", "sequence_id"], name: "index_events_on_map_id_and_sequence_id", unique: true, using: :btree + add_index "events", ["map_id"], name: "index_events_on_map_id", using: :btree + add_index "events", ["sequence_id"], name: "index_events_on_sequence_id", using: :btree + add_index "events", ["user_id"], name: "index_events_on_user_id", using: :btree + create_table "in_metacode_sets", force: :cascade do |t| t.integer "metacode_id" t.integer "metacode_set_id" @@ -63,6 +96,19 @@ ActiveRecord::Schema.define(version: 20160120061513) do add_index "maps", ["user_id"], name: "index_maps_on_user_id", using: :btree + create_table "messages", force: :cascade do |t| + t.text "message" + t.integer "user_id" + t.integer "resource_id" + t.string "resource_type" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "messages", ["resource_id"], name: "index_messages_on_resource_id", using: :btree + add_index "messages", ["resource_type"], name: "index_messages_on_resource_type", using: :btree + add_index "messages", ["user_id"], name: "index_messages_on_user_id", using: :btree + create_table "metacode_sets", force: :cascade do |t| t.string "name" t.text "desc" @@ -76,12 +122,56 @@ ActiveRecord::Schema.define(version: 20160120061513) do create_table "metacodes", force: :cascade do |t| t.text "name" - t.string "icon" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.string "manual_icon" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false t.string "color" + t.string "aws_icon_file_name" + t.string "aws_icon_content_type" + t.integer "aws_icon_file_size" + t.datetime "aws_icon_updated_at" end + create_table "oauth_access_grants", force: :cascade do |t| + t.integer "resource_owner_id", null: false + t.integer "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "scopes" + end + + add_index "oauth_access_grants", ["token"], name: "index_oauth_access_grants_on_token", unique: true, using: :btree + + create_table "oauth_access_tokens", force: :cascade do |t| + t.integer "resource_owner_id" + t.integer "application_id" + t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.string "scopes" + end + + add_index "oauth_access_tokens", ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true, using: :btree + add_index "oauth_access_tokens", ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id", using: :btree + add_index "oauth_access_tokens", ["token"], name: "index_oauth_access_tokens_on_token", unique: true, using: :btree + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree + create_table "synapses", force: :cascade do |t| t.text "desc" t.text "category" @@ -100,6 +190,16 @@ ActiveRecord::Schema.define(version: 20160120061513) do add_index "synapses", ["node2_id"], name: "index_synapses_on_node2_id", using: :btree add_index "synapses", ["user_id"], name: "index_synapses_on_user_id", using: :btree + create_table "tokens", force: :cascade do |t| + t.string "token" + t.string "description" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "tokens", ["user_id"], name: "index_tokens_on_user_id", using: :btree + create_table "topics", force: :cascade do |t| t.text "name" t.text "desc" @@ -154,4 +254,15 @@ ActiveRecord::Schema.define(version: 20160120061513) do add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree + create_table "webhooks", force: :cascade do |t| + t.integer "hookable_id" + t.string "hookable_type" + t.string "kind", null: false + t.string "uri", null: false + t.text "event_types", default: [], array: true + end + + add_index "webhooks", ["hookable_type", "hookable_id"], name: "index_webhooks_on_hookable_type_and_hookable_id", using: :btree + + add_foreign_key "tokens", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index 51aa01d9..daece06f 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -24,283 +24,283 @@ User.new({ ## METACODES Metacode.create({ name: 'Action', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_action.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_action.png', color: '#BD6C85' }) Metacode.create({ name: 'Activity', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_activity.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_activity.png', color: '#6EBF65' }) Metacode.create({ name: 'Catalyst', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_catalyst.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_catalyst.png', color: '#EF8964', }) Metacode.create({ name: 'Closed', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_closedissue.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_closedissue.png', color: '#ABB49F', }) Metacode.create({ name: 'Process', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_process.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_process.png', color: '#BDB25E', }) Metacode.create({ name: 'Future', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_futuredev.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_futuredev.png', color: '#25A17F', }) Metacode.create({ name: 'Group', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_group.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_group.png', color: '#7076BC', }) Metacode.create({ name: 'Implication', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_implication.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_implication.png', color: '#83DECA', }) Metacode.create({ name: 'Insight', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_insight.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_insight.png', color: '#B074AD', }) Metacode.create({ name: 'Intention', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_intention.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_intention.png', color: '#BAEAFF', }) Metacode.create({ name: 'Knowledge', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_knowledge.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_knowledge.png', color: '#60ACF7', }) Metacode.create({ name: 'Location', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_location.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_location.png', color: '#ABD9A7', }) Metacode.create({ name: 'Need', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_need.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_need.png', color: '#D2A7D4', }) Metacode.create({ name: 'Open', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_openissue.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_openissue.png', color: '#9BBF71', }) Metacode.create({ name: 'Opportunity', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_opportunity.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_opportunity.png', color: '#889F64', }) Metacode.create({ name: 'Person', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_person.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_person.png', color: '#DE925F', }) Metacode.create({ name: 'Platform', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_platform.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_platform.png', color: '#21C8FE', }) Metacode.create({ name: 'Problem', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_problem.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_problem.png', color: '#99CFC4', }) Metacode.create({ name: 'Resource', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_resource.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_resource.png', color: '#C98C63', }) Metacode.create({ name: 'Role', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_role.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_role.png', color: '#A8595D', }) Metacode.create({ name: 'Task', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_task.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_task.png', color: '#3397C4', }) Metacode.create({ name: 'Trajectory', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_trajectory.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/blueprint/96px/bp_trajectory.png', color: '#D3AA4C', }) Metacode.create({ name: 'Argument', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_argument.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_argument.png', color: '#7FAEFD', }) Metacode.create({ name: 'Con', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_con.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_con.png', color: '#CF7C74', }) Metacode.create({ name: 'Subject', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_subject.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_subject.png', color: '#8293D8', }) Metacode.create({ name: 'Decision', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_decision.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_decision.png', color: '#CCA866', }) Metacode.create({ name: 'Event', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_event.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_event.png', color: '#F5854B', }) Metacode.create({ name: 'Example', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_example.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_example.png', color: '#618C61', }) Metacode.create({ name: 'Experience', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_experience.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_experience.png', color: '#BE995F', }) Metacode.create({ name: 'Feedback', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_feedback.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_feedback.png', color: '#54A19D', }) Metacode.create({ name: 'Aim', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_aim.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_aim.png', color: '#B0B0B0', }) Metacode.create({ name: 'Good', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_goodpractice.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_goodpractice.png', color: '#BD9E86', }) Metacode.create({ name: 'Idea', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_idea.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_idea.png', color: '#C4BC5E', }) Metacode.create({ name: 'List', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_list.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_list.png', color: '#B7A499', }) Metacode.create({ name: 'Media', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_media.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_media.png', color: '#6D94CC', }) Metacode.create({ name: 'Metamap', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_metamap.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_metamap.png', color: '#AEA9FD', }) Metacode.create({ name: 'Model', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_model.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_model.png', color: '#B385BA', }) Metacode.create({ name: 'Note', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_note.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_note.png', color: '#A389A1', }) Metacode.create({ name: 'Perspective', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_perspective.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_perspective.png', color: '#2EB6CC', }) Metacode.create({ name: 'Pro', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_pro.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_pro.png', color: '#89B879', }) Metacode.create({ name: 'Project', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_project.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_project.png', color: '#85A050', }) Metacode.create({ name: 'Question', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_question.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_question.png', color: '#5CB3B3', }) Metacode.create({ name: 'Reference', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_reference.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_reference.png', color: '#A7A7A7', }) Metacode.create({ name: 'Research', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_research.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_research.png', color: '#CD8E89', }) Metacode.create({ name: 'Status', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_status.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_status.png', color: '#EFA7C0', }) Metacode.create({ name: 'Tool', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_tool.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_tool.png', color: '#828282', }) Metacode.create({ name: 'Wildcard', - icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_wildcard.png', + manual_icon: 'https://s3.amazonaws.com/metamaps-assets/metacodes/generics/96px/gen_wildcard.png', color: '#73C7DE', }) ## END METACODES diff --git a/doc/RailsIntroduction.md b/doc/RailsIntroduction.md new file mode 100644 index 00000000..b0734417 --- /dev/null +++ b/doc/RailsIntroduction.md @@ -0,0 +1,38 @@ +# How does Ruby on Rails work? + +Ruby on Rails is a pretty intimidating framework to get started with, since there are so many files. Here's a quick rundown on getting started: + +1. Where should I look for code? +2. How do I know what code generates what pages of metamaps.cc? + +## Where should I look for code? + +Here are the top level folders you should know about: + +- app: holds the ruby code + assets that make up the app. This is the only directory you really need to see how the app works. +- spec: tests describing how the code *should* work +- db: code for handling interaction with the underlying Postgresql database +- config: low-level, in-depth configuration variables. The most interesting file is `config/routes.rb`. +- Gemfile: listing of app dependencies from https://rubygems.org/ +- realtime: code for our Node.JS realtime server. This is a separate server written in Javascript that isn't served by ruby on rails. + +Within the app/ folder, you can find these important folders: + +- models: files describing the logic surrounding maps, topics, synapses and more in the framework +- views: HTML template files that allow you to generate HTML using ruby code +- helpers: globally accessible helper functions available to views; they help us take logic out of the view files +- controllers: functions that map a route (e.g. `GET https://metamaps.cc/maps/2`) to a controller action (e.g. maps_controller.rb's `show` function). +- services: files that encapsulate a certain feature or logic into one file that can be referenced. Usually services help us take logic out of models and controllers. +- assets/stylesheets: CSS stylesheets for look and feel +- assets/javascripts: This is a huge folder, containing all of our Javascript code. This folder itself is at least as important as the rest of the repository. + +## How do I know what code generates what pages of metamaps.cc? + +The lifecycle works something like this. + +1. run `rake routes` inside the metamaps_gen002 directory on your computer, and it will generate a list with entries looking something like `GET /maps/:id maps#show`. This tells you which URL will end up at which *controller*. In this example, if you accessed `https://metamaps.cc/maps/2`, you are looking for the maps_controller's `show` function, and there will be a variable params["id"] that is equal to 2. +2. Now in `app/controllers/maps_controller.rb`, you can find the function. It should do some calculations, create an instance variable @map, and then do one of two things: + - If it doesn't call anything, ruby on rails will automatically load app/views/map/show.html.erb. (NB: If you loaded `/maps/2.json`, it would look for app/views/map/show.json.erb). Any instance variables assigned (e.g. @map) will be available to the view file (show.html.erb). + - You can also call the render function directly. See the codebase or http://guides.rubyonrails.org/layouts_and_rendering.html#using-render for details. +3. The map's show template (show.html.erb) will contain actual HTML, which gets us a lot closer to an HTML page. Ruby on rails will fill in a "layout" from app/views/layouts to wrap the content of the page. It will also let you include code with `<% %>` (for logical operations) or `<%= %>` (to print a ruby string directly to the HTML page). The view may refer to attributes on the @map object passed from the controller. For more details on how the @map object works, you can check its definition in app/models/map.rb. +4. The shortest possible rails model file would look like this: `class Map < ActiveRecord::Base; end`. In this case, rails would look for a database table called "maps" and allow access to the columns. For instance, a postgresql INTEGER column called "id" would be accessible as @map.id. However, you can also specify validations, shorthand queries called scopes, and helper functions that specify the logic of the model. It is generally preferable to put logic in the model rather than in a controller or view, so these files are excellent sources of information about how the app works. diff --git a/doc/WindowsInstallation.md b/doc/WindowsInstallation.md index 59e21298..0446fab9 100644 --- a/doc/WindowsInstallation.md +++ b/doc/WindowsInstallation.md @@ -19,7 +19,7 @@ Now you are ready to clone the Metamaps git repository: The third `bundle install` command downloads and installs the rubygem dependencies of Metamaps. - + At this point you should be in C:\git\metamaps_gen002, or whatever equivalent directory you've chosen. The next step is to set up your database configuration. From the metamaps_gen002 directory, run @@ -41,7 +41,7 @@ time with only one command; you don't need to repeat any of the previous steps again. The command to run the server is: rails s - + Navigate your browser to localhost:3000 once you have the server running Sign in with the default account diff --git a/public/famous/main.js b/public/famous/main.js index c14f5c67..2a147e83 100644 --- a/public/famous/main.js +++ b/public/famous/main.js @@ -296,6 +296,9 @@ Metamaps.Famous.build = function () { { duration: 300, curve: 'easeIn' } ); }; + f.explore.setApps = function (section) { + f.explore.surf.setContent(templates[section + 'AppsContent']); + }; f.explore.set = function (section, mapperId) { var loggedIn = Metamaps.Active.Mapper ? 'Auth' : ''; @@ -402,4 +405,4 @@ Metamaps.Famous.build = function () { f.logo.show(); }// build -}); \ No newline at end of file +}); diff --git a/public/famous/templates.js b/public/famous/templates.js index 01aee3ca..66e6b9ac 100644 --- a/public/famous/templates.js +++ b/public/famous/templates.js @@ -30,5 +30,12 @@ t.logoContent += ''; t.featuredAuthContent += '
Recently Active
'; t.featuredAuthContent += '
Featured
'; +/* apps bars */ + t.registeredAppsContent = '
Registered Apps
'; + t.registeredAppsContent += '
Authorized Apps
'; + + t.authorizedAppsContent = '
Registered Apps
'; + t.authorizedAppsContent += '
Authorized Apps
'; + module.exports = t; }); diff --git a/realtime/README.md b/realtime/README.md index 5c81979b..6aad3e60 100644 --- a/realtime/README.md +++ b/realtime/README.md @@ -1,18 +1,18 @@ -## Node.js realtime server - -To run the server, you need to install the dependencies and run the server. -Please ensure you have followed the OS-specific instructions in doc/ to -install NodeJS. Once you have node, then you can proceed to install the -node packages for the realtime server: - - cd realtime - npm install #creates node_modules directory - node realtime-server.js - -That's it! - -To run the server as a daemon that will be re-run if it crashes, you can -use the forever node package. - - sudo npm install -g forever - forever start realtime-server.js +## Node.js realtime server + +To run the server, you need to install the dependencies and run the server. +Please ensure you have followed the OS-specific instructions in doc/ to +install NodeJS. Once you have node, then you can proceed to install the +node packages for the realtime server: + + cd realtime + npm install #creates node_modules directory + node realtime-server.js + +That's it! + +To run the server as a daemon that will be re-run if it crashes, you can +use the forever node package. + + sudo npm install -g forever + forever start realtime-server.js diff --git a/realtime/package.json b/realtime/package.json index 0bedc1c4..5b5b08f4 100644 --- a/realtime/package.json +++ b/realtime/package.json @@ -4,6 +4,7 @@ "version": "0.0.1", "private": true, "dependencies": { - "socket.io": "0.9.12" + "socket.io": "0.9.12", + "node-uuid": "1.2.0" } } diff --git a/realtime/realtime-server.js b/realtime/realtime-server.js index 41f1cc3d..6ce9aa44 100644 --- a/realtime/realtime-server.js +++ b/realtime/realtime-server.js @@ -1,7 +1,14 @@ -var io = require('socket.io').listen(5001); +var + io = require('socket.io').listen(5001), + signalServer = require('./signal'), + stunservers = [{"url": "stun:stun.l.google.com:19302"}]; + +io.set('log', false); function start() { + signalServer(io, stunservers); + io.on('connection', function (socket) { // this will ping a new person with awareness of who's already on the map @@ -10,11 +17,43 @@ function start() { userid: data.userid, username: data.username, userrealtime: data.userrealtime, + userinconversation: data.userinconversation, userimage: data.userimage }; socket.broadcast.emit(data.userToNotify + '-' + data.mapid + '-UpdateMapperList', existingUser); }); + // as a new mapper check whether there's a call in progress to join + socket.on('checkForCall', function (data) { + var socketsInRoom = io.sockets.clients(data.room); + if (socketsInRoom.length) socket.emit('maps-' + data.mapid + '-callInProgress'); + }); + // send the invitation to start a call + socket.on('inviteACall', function (data) { + socket.broadcast.emit(data.invited + '-' + data.mapid + '-invitedToCall', data.inviter); + }); + // send an invitation to join a call in progress + socket.on('inviteToJoin', function (data) { + socket.broadcast.emit(data.invited + '-' + data.mapid + '-invitedToJoin', data.inviter); + }); + // send response back to the inviter + socket.on('callAccepted', function (data) { + socket.broadcast.emit(data.inviter + '-' + data.mapid + '-callAccepted', data.invited); + socket.broadcast.emit('maps-' + data.mapid + '-callStarting'); + }); + socket.on('callDenied', function (data) { + socket.broadcast.emit(data.inviter + '-' + data.mapid + '-callDenied', data.invited); + }); + socket.on('inviteDenied', function (data) { + socket.broadcast.emit(data.inviter + '-' + data.mapid + '-inviteDenied', data.invited); + }); + socket.on('mapperJoinedCall', function (data) { + socket.broadcast.emit('maps-' + data.mapid + '-mapperJoinedCall', data.id); + }); + socket.on('mapperLeftCall', function (data) { + socket.broadcast.emit('maps-' + data.mapid + '-mapperLeftCall', data.id); + }); + // this will ping everyone on a map that there's a person just joined the map socket.on('newMapperNotify', function (data) { socket.set('mapid', data.mapid); @@ -49,7 +88,7 @@ function start() { // this will ping everyone on a map that there's a person just left the map socket.on('disconnect', end); socket.on('endMapperNotify', end); - + // this will ping everyone on a map that someone just turned on realtime socket.on('notifyStartRealtime', function (data) { var newUser = { @@ -59,7 +98,7 @@ function start() { socket.broadcast.emit('maps-' + data.mapid + '-newrealtime', newUser); }); - + // this will ping everyone on a map that someone just turned on realtime socket.on('notifyStopRealtime', function (data) { var newUser = { @@ -86,6 +125,13 @@ function start() { socket.broadcast.emit('maps-' + mapId + '-topicDrag', data); }); + socket.on('newMessage', function (data) { + var mapId = data.mapid; + delete data.mapid; + + socket.broadcast.emit('maps-' + mapId + '-newMessage', data); + }); + socket.on('newTopic', function (data) { var mapId = data.mapid; delete data.mapid; @@ -137,4 +183,4 @@ function start() { }); } -start(); \ No newline at end of file +start(); diff --git a/realtime/signal.js b/realtime/signal.js new file mode 100644 index 00000000..9b22197a --- /dev/null +++ b/realtime/signal.js @@ -0,0 +1,111 @@ +var uuid = require('node-uuid'); + +module.exports = function(io, stunservers) { + + var + activePeople = 0; + + function describeRoom(name) { + var clients = io.sockets.clients(name); + var result = { + clients: {} + }; + clients.forEach(function (client) { + result.clients[client.id] = client.resources; + }); + return result; + } + + function safeCb(cb) { + if (typeof cb === 'function') { + return cb; + } else { + return function () {}; + } + } + + io.sockets.on('connection', function (client) { + activePeople += 1; + + client.resources = { + screen: false, + video: true, + audio: false + }; + + // pass a message to another id + client.on('message', function (details) { + if (!details) return; + + var otherClient = io.sockets.sockets[details.to]; + if (!otherClient) return; + + details.from = client.id; + otherClient.emit('message', details); + }); + + client.on('shareScreen', function () { + client.resources.screen = true; + }); + + client.on('unshareScreen', function (type) { + client.resources.screen = false; + removeFeed('screen'); + }); + + client.on('join', join); + + function removeFeed(type) { + if (client.room) { + io.sockets.in(client.room).emit('remove', { + id: client.id, + type: type + }); + if (!type) { + client.leave(client.room); + client.room = undefined; + } + } + } + + function join(name, cb) { + // sanity check + if (typeof name !== 'string') return; + // leave any existing rooms + removeFeed(); + safeCb(cb)(null, describeRoom(name)); + client.join(name); + client.room = name; + } + + // we don't want to pass "leave" directly because the + // event type string of "socket end" gets passed too. + client.on('disconnect', function () { + removeFeed(); + activePeople -= 1; + }); + client.on('leave', function () { + removeFeed(); + }); + + client.on('create', function (name, cb) { + if (arguments.length == 2) { + cb = (typeof cb == 'function') ? cb : function () {}; + name = name || uuid(); + } else { + cb = name; + name = uuid(); + } + // check if exists + if (io.sockets.clients(name).length) { + safeCb(cb)('taken'); + } else { + join(name); + safeCb(cb)(null, name); + } + }); + + // tell client about stun and turn servers and generate nonces + client.emit('stunservers', stunservers || []); + }); +}; diff --git a/script/phantomjs-save-screenshot.js b/script/phantomjs-save-screenshot.js deleted file mode 100755 index 5ca47835..00000000 --- a/script/phantomjs-save-screenshot.js +++ /dev/null @@ -1,57 +0,0 @@ -//parse arguments passed from command line (or more likely, from rails) -var system = require('system'); -var args = system.args; -if (args.length <= 1) { - phantom.exit(); - throw new Error("no arguments supplied on command line"); -}//if - -//configurable variables - CHANGE ME -var mapID = args[1]; -var environment = args[2]; -var address = environment === 'development' ? 'http://localhost:3000' : 'http://metamaps.herokuapp.com'; -var url = address + '/maps/' + mapID; -var width = 940; -var height = 630; - -//set up page and the area we'll render as a PNG -var page = require('webpage').create(); -page.viewportSize = { - width: width, - height: height -}; - -page.open(url, function (status) { - if (status === 'success') { - //since this isn't evaluateAsync, it should also ensure the asynchronous - //js stuff is loaded too, hopefully? - - page.onCallback = function(data){ - - //pass to ruby - console.log(page.renderBase64('PNG')); - - //render to the metamaps_gen002 directory for debug - //page.render('map1.png', 'PNG'); - - phantom.exit(); - }; - - page.evaluate(function() { - - $(document).ready(function () { - //$(document).on(Metamaps.JIT.events.animationDone, function() { - setTimeout(function(){ - $('.upperLeftUI, .upperRightUI, .mapControls, .infoAndHelp, .uv-icon, .footer').hide(); - Metamaps.JIT.zoomExtents(); - window.callPhantom(); - }, 5000); - }); - - });//page.evaluate - - } else { - //failed to load - phantom.exit(); - }//if -}); diff --git a/spec/controllers/mappings_controller_spec.rb b/spec/controllers/mappings_controller_spec.rb index 8a7acda5..ffc9bac9 100644 --- a/spec/controllers/mappings_controller_spec.rb +++ b/spec/controllers/mappings_controller_spec.rb @@ -1,60 +1,16 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. - RSpec.describe MappingsController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # Mapping. As you add validations to Mapping, be sure to - # adjust the attributes here as well. - let(:valid_attributes) do - skip('Add a hash of attributes valid for your model') - end - - let(:invalid_attributes) do - skip('Add a hash of attributes invalid for your model') - end - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # MappingsController. Be sure to keep this updated too. - let(:valid_session) { {} } - - describe 'GET #index' do - it 'assigns all mappings as @mappings' do - mapping = Mapping.create! valid_attributes - get :index, {}, valid_session - expect(assigns(:mappings)).to eq([mapping]) - end + let!(:mapping) { create(:mapping) } + let(:valid_attributes) { mapping.attributes.except('id') } + let(:invalid_attributes) { { xloc: 0 } } + before :each do + sign_in end describe 'GET #show' do it 'assigns the requested mapping as @mapping' do - mapping = Mapping.create! valid_attributes - get :show, { id: mapping.to_param }, valid_session - expect(assigns(:mapping)).to eq(mapping) - end - end - - describe 'GET #edit' do - it 'assigns the requested mapping as @mapping' do - mapping = Mapping.create! valid_attributes - get :edit, { id: mapping.to_param }, valid_session + get :show, { id: mapping.to_param } expect(assigns(:mapping)).to eq(mapping) end end @@ -63,98 +19,56 @@ RSpec.describe MappingsController, type: :controller do context 'with valid params' do it 'creates a new Mapping' do expect do - post :create, { mapping: valid_attributes }, valid_session + post :create, { mapping: valid_attributes } end.to change(Mapping, :count).by(1) end it 'assigns a newly created mapping as @mapping' do - post :create, { mapping: valid_attributes }, valid_session + post :create, { mapping: valid_attributes } expect(assigns(:mapping)).to be_a(Mapping) expect(assigns(:mapping)).to be_persisted end - - it 'redirects to the created mapping' do - post :create, { mapping: valid_attributes }, valid_session - expect(response).to redirect_to(Mapping.last) - end end context 'with invalid params' do it 'assigns a newly created but unsaved mapping as @mapping' do - post :create, { mapping: invalid_attributes }, valid_session + post :create, { mapping: invalid_attributes } expect(assigns(:mapping)).to be_a_new(Mapping) end - - it "re-renders the 'new' template" do - post :create, { mapping: invalid_attributes }, valid_session - expect(response).to render_template('new') - end end end describe 'PUT #update' do context 'with valid params' do - let(:new_attributes) do - skip('Add a hash of attributes valid for your model') - end + let(:new_attributes) { build(:mapping_random_location).attributes.except('id') } it 'updates the requested mapping' do - mapping = Mapping.create! valid_attributes put :update, - { id: mapping.to_param, mapping: new_attributes }, - valid_session + { id: mapping.to_param, mapping: new_attributes } mapping.reload - skip('Add assertions for updated state') end it 'assigns the requested mapping as @mapping' do - mapping = Mapping.create! valid_attributes put :update, - { id: mapping.to_param, mapping: valid_attributes }, - valid_session + { id: mapping.to_param, mapping: valid_attributes } expect(assigns(:mapping)).to eq(mapping) end - - it 'redirects to the mapping' do - mapping = Mapping.create! valid_attributes - put :update, - { id: mapping.to_param, mapping: valid_attributes }, - valid_session - expect(response).to redirect_to(mapping) - end end context 'with invalid params' do it 'assigns the mapping as @mapping' do - mapping = Mapping.create! valid_attributes put :update, - { id: mapping.to_param, mapping: invalid_attributes }, - valid_session + { id: mapping.to_param, mapping: invalid_attributes } expect(assigns(:mapping)).to eq(mapping) end - - it "re-renders the 'edit' template" do - mapping = Mapping.create! valid_attributes - put :update, - { id: mapping.to_param, mapping: invalid_attributes }, - valid_session - expect(response).to render_template('edit') - end end end describe 'DELETE #destroy' do it 'destroys the requested mapping' do - mapping = Mapping.create! valid_attributes expect do - delete :destroy, { id: mapping.to_param }, valid_session + delete :destroy, { id: mapping.to_param } end.to change(Mapping, :count).by(-1) end - - it 'redirects to the mappings list' do - mapping = Mapping.create! valid_attributes - delete :destroy, { id: mapping.to_param }, valid_session - expect(response).to redirect_to(mappings_url) - end end end diff --git a/spec/controllers/maps_controller_spec.rb b/spec/controllers/maps_controller_spec.rb index fdaa064a..35c3ddcc 100644 --- a/spec/controllers/maps_controller_spec.rb +++ b/spec/controllers/maps_controller_spec.rb @@ -1,60 +1,30 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. - RSpec.describe MapsController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # Map. As you add validations to Map, be sure to - # adjust the attributes here as well. - let(:valid_attributes) do - skip('Add a hash of attributes valid for your model') + let(:map) { create(:map) } + let(:valid_attributes) { map.attributes.except(:id) } + let(:invalid_attributes) { { permission: :commons } } + before :each do + sign_in end - let(:invalid_attributes) do - skip('Add a hash of attributes invalid for your model') - end - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # MapsController. Be sure to keep this updated too. - let(:valid_session) { {} } - describe 'GET #index' do - it 'assigns all maps as @maps' do - map = Map.create! valid_attributes - get :index, {}, valid_session + it 'viewable maps as @maps' do + get :activemaps expect(assigns(:maps)).to eq([map]) end end + describe 'GET #contains' do + it 'returns json matching schema' do + get :contains, { id: map.to_param, format: :json } + expect(response.body).to match_json_schema(:map_contains) + end + end + describe 'GET #show' do it 'assigns the requested map as @map' do - map = Map.create! valid_attributes - get :show, { id: map.to_param }, valid_session - expect(assigns(:map)).to eq(map) - end - end - - describe 'GET #edit' do - it 'assigns the requested map as @map' do - map = Map.create! valid_attributes - get :edit, { id: map.to_param }, valid_session + get :show, { id: map.to_param } expect(assigns(:map)).to eq(map) end end @@ -62,99 +32,74 @@ RSpec.describe MapsController, type: :controller do describe 'POST #create' do context 'with valid params' do it 'creates a new Map' do + map.reload expect do - post :create, { map: valid_attributes }, valid_session + post :create, valid_attributes.merge(format: :json) end.to change(Map, :count).by(1) end it 'assigns a newly created map as @map' do - post :create, { map: valid_attributes }, valid_session + post :create, valid_attributes.merge(format: :json) expect(assigns(:map)).to be_a(Map) expect(assigns(:map)).to be_persisted end - - it 'redirects to the created map' do - post :create, { map: valid_attributes }, valid_session - expect(response).to redirect_to(Map.last) - end end context 'with invalid params' do it 'assigns a newly created but unsaved map as @map' do - post :create, { map: invalid_attributes }, valid_session + post :create, invalid_attributes.merge(format: :json) expect(assigns(:map)).to be_a_new(Map) end - - it "re-renders the 'new' template" do - post :create, { map: invalid_attributes }, valid_session - expect(response).to render_template('new') - end end end describe 'PUT #update' do context 'with valid params' do - let(:new_attributes) do - skip('Add a hash of attributes valid for your model') - end + let(:new_attributes) { { name: "Uncool map", permission: :private } } it 'updates the requested map' do - map = Map.create! valid_attributes put :update, - { id: map.to_param, map: new_attributes }, - valid_session - map.reload - skip('Add assertions for updated state') + { id: map.to_param, map: new_attributes, format: :json } + expect(assigns(:map).name).to eq "Uncool map" + expect(assigns(:map).permission).to eq 'private' end it 'assigns the requested map as @map' do - map = Map.create! valid_attributes put :update, - { id: map.to_param, map: valid_attributes }, - valid_session + { id: map.to_param, map: valid_attributes, format: :json } expect(assigns(:map)).to eq(map) end - - it 'redirects to the map' do - map = Map.create! valid_attributes - put :update, - { id: map.to_param, map: valid_attributes }, - valid_session - expect(response).to redirect_to(map) - end end context 'with invalid params' do it 'assigns the map as @map' do - map = Map.create! valid_attributes put :update, - { id: map.to_param, map: invalid_attributes }, - valid_session + { id: map.to_param, map: invalid_attributes, format: :json } expect(assigns(:map)).to eq(map) end - - it "re-renders the 'edit' template" do - map = Map.create! valid_attributes - put :update, - { id: map.to_param, map: invalid_attributes }, - valid_session - expect(response).to render_template('edit') - end end end describe 'DELETE #destroy' do - it 'destroys the requested map' do - map = Map.create! valid_attributes + let(:unowned_map) { create(:map) } + let(:owned_map) { create(:map, user: controller.current_user) } + + it 'prevents deletion by non-owners' do + unowned_map.reload expect do - delete :destroy, { id: map.to_param }, valid_session - end.to change(Map, :count).by(-1) + delete :destroy, { id: unowned_map.to_param, format: :json } + end.to change(Map, :count).by(0) + expect(response.body).to eq '' + expect(response.status).to eq 403 end - it 'redirects to the maps list' do - map = Map.create! valid_attributes - delete :destroy, { id: map.to_param }, valid_session - expect(response).to redirect_to(maps_url) + it 'deletes owned map' do + owned_map.reload # ensure it's in the database + expect do + delete :destroy, { id: owned_map.to_param, format: :json } + end.to change(Map, :count).by(-1) + expect(response.body).to eq '' + expect(response.status).to eq 204 end end end diff --git a/spec/controllers/metacodes_controller_spec.rb b/spec/controllers/metacodes_controller_spec.rb index 6e1ba2b9..48688117 100644 --- a/spec/controllers/metacodes_controller_spec.rb +++ b/spec/controllers/metacodes_controller_spec.rb @@ -1,72 +1,30 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. - RSpec.describe MetacodesController, type: :controller do + let(:metacode) { create(:metacode) } + let(:valid_attributes) { metacode.attributes.except('id') } before :each do - @user = create(:user, admin: true) - sign_in @user + sign_in create(:user, admin: true) end - # This should return the minimal set of attributes required to create a valid - # Metacode. As you add validations to Metacode, be sure to - # adjust the attributes here as well. - let(:valid_attributes) do - skip('Add a hash of attributes valid for your model') - end - - let(:invalid_attributes) do - skip('Add a hash of attributes invalid for your model') - end - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # MetacodesController. Be sure to keep this updated too. - let(:valid_session) { {} } - describe 'GET #index' do it 'assigns all metacodes as @metacodes' do - metacode = Metacode.create! valid_attributes - get :index, {}, valid_session - expect(assigns(:metacodes)).to eq([metacode]) - end - end - - describe 'GET #show' do - it 'assigns the requested metacode as @metacode' do - metacode = Metacode.create! valid_attributes - get :show, { id: metacode.to_param }, valid_session - expect(assigns(:metacode)).to eq(metacode) + metacode.reload # ensure it's created + get :index, {} + expect(Metacode.all.to_a).to eq([metacode]) end end describe 'GET #new' do it 'assigns a new metacode as @metacode' do - get :new, {}, valid_session + get :new, { format: :json } expect(assigns(:metacode)).to be_a_new(Metacode) end end describe 'GET #edit' do it 'assigns the requested metacode as @metacode' do - metacode = Metacode.create! valid_attributes - get :edit, { id: metacode.to_param }, valid_session + get :edit, { id: metacode.to_param } expect(assigns(:metacode)).to eq(metacode) end end @@ -74,32 +32,22 @@ RSpec.describe MetacodesController, type: :controller do describe 'POST #create' do context 'with valid params' do it 'creates a new Metacode' do + metacode.reload # ensure it's present to start expect do - post :create, { metacode: valid_attributes }, valid_session + post :create, { metacode: valid_attributes } end.to change(Metacode, :count).by(1) end - it 'assigns a newly created metacode as @metacode' do - post :create, { metacode: valid_attributes }, valid_session + it 'has the correct attributes' do + post :create, { metacode: valid_attributes } + # expect(Metacode.last.attributes.expect(:id)).to eq(metacode.attributes.except(:id)) expect(assigns(:metacode)).to be_a(Metacode) expect(assigns(:metacode)).to be_persisted end - it 'redirects to the created metacode' do - post :create, { metacode: valid_attributes }, valid_session - expect(response).to redirect_to(Metacode.last) - end - end - - context 'with invalid params' do - it 'assigns a newly created but unsaved metacode as @metacode' do - post :create, { metacode: invalid_attributes }, valid_session - expect(assigns(:metacode)).to be_a_new(Metacode) - end - - it "re-renders the 'new' template" do - post :create, { metacode: invalid_attributes }, valid_session - expect(response).to render_template('new') + it 'redirects to the metacode index' do + post :create, { metacode: valid_attributes } + expect(response).to redirect_to(metacodes_url) end end end @@ -107,66 +55,34 @@ RSpec.describe MetacodesController, type: :controller do describe 'PUT #update' do context 'with valid params' do let(:new_attributes) do - skip('Add a hash of attributes valid for your model') + { manual_icon: 'https://newimages.ca/cool-image.jpg', + aws_icon: nil, + color: '#ffffff', + name: 'Cognition' } end it 'updates the requested metacode' do - metacode = Metacode.create! valid_attributes put :update, - { id: metacode.to_param, metacode: new_attributes }, - valid_session + { id: metacode.to_param, metacode: new_attributes } metacode.reload - skip('Add assertions for updated state') - end - - it 'assigns the requested metacode as @metacode' do - metacode = Metacode.create! valid_attributes - put :update, - { id: metacode.to_param, metacode: valid_attributes }, - valid_session - expect(assigns(:metacode)).to eq(metacode) - end - - it 'redirects to the metacode' do - metacode = Metacode.create! valid_attributes - put :update, - { id: metacode.to_param, metacode: valid_attributes }, - valid_session - expect(response).to redirect_to(metacode) - end - end - - context 'with invalid params' do - it 'assigns the metacode as @metacode' do - metacode = Metacode.create! valid_attributes - put :update, - { id: metacode.to_param, metacode: invalid_attributes }, - valid_session - expect(assigns(:metacode)).to eq(metacode) - end - - it "re-renders the 'edit' template" do - metacode = Metacode.create! valid_attributes - put :update, - { id: metacode.to_param, metacode: invalid_attributes }, - valid_session - expect(response).to render_template('edit') + expect(metacode.icon).to eq 'https://newimages.ca/cool-image.jpg' + expect(metacode.color).to eq '#ffffff' + expect(metacode.name).to eq 'Cognition' end end end - describe 'DELETE #destroy' do - it 'destroys the requested metacode' do - metacode = Metacode.create! valid_attributes - expect do - delete :destroy, { id: metacode.to_param }, valid_session - end.to change(Metacode, :count).by(-1) + context 'not admin' do + it 'denies access to create' do + sign_in create(:user, admin: false) + post :create, { metacode: valid_attributes } + expect(response).to redirect_to root_url end - it 'redirects to the metacodes list' do - metacode = Metacode.create! valid_attributes - delete :destroy, { id: metacode.to_param }, valid_session - expect(response).to redirect_to(metacodes_url) + it 'denies access to update' do + sign_in create(:user, admin: false) + post :update, { id: metacode.to_param, metacode: valid_attributes } + expect(response).to redirect_to root_url end end end diff --git a/spec/controllers/synapses_controller_spec.rb b/spec/controllers/synapses_controller_spec.rb index ff05ea6f..478ee6ed 100644 --- a/spec/controllers/synapses_controller_spec.rb +++ b/spec/controllers/synapses_controller_spec.rb @@ -1,60 +1,16 @@ require 'rails_helper' -# This spec was generated by rspec-rails when you ran the scaffold generator. -# It demonstrates how one might use RSpec to specify the controller code that -# was generated by Rails when you ran the scaffold generator. -# -# It assumes that the implementation code is generated by the rails scaffold -# generator. If you are using any extension libraries to generate different -# controller code, this generated spec may or may not pass. -# -# It only uses APIs available in rails and/or rspec-rails. There are a number -# of tools you can use to make these specs even more expressive, but we're -# sticking to rails and rspec-rails APIs to keep things simple and stable. -# -# Compared to earlier versions of this generator, there is very limited use of -# stubs and message expectations in this spec. Stubs are only used when there -# is no simpler way to get a handle on the object needed for the example. -# Message expectations are only used when there is no simpler way to specify -# that an instance is receiving a specific message. - RSpec.describe SynapsesController, type: :controller do - # This should return the minimal set of attributes required to create a valid - # Synapse. As you add validations to Synapse, be sure to - # adjust the attributes here as well. - let(:valid_attributes) do - skip('Add a hash of attributes valid for your model') - end - - let(:invalid_attributes) do - skip('Add a hash of attributes invalid for your model') - end - - # This should return the minimal set of values that should be in the session - # in order to pass any filters (e.g. authentication) defined in - # SynapsesController. Be sure to keep this updated too. - let(:valid_session) { {} } - - describe 'GET #index' do - it 'assigns all synapses as @synapses' do - synapse = Synapse.create! valid_attributes - get :index, {}, valid_session - expect(assigns(:synapses)).to eq([synapse]) - end + let(:synapse) { create(:synapse) } + let(:valid_attributes) { synapse.attributes.except('id') } + let(:invalid_attributes) { { permission: :invalid_lol } } + before :each do + sign_in end describe 'GET #show' do it 'assigns the requested synapse as @synapse' do - synapse = Synapse.create! valid_attributes - get :show, { id: synapse.to_param }, valid_session - expect(assigns(:synapse)).to eq(synapse) - end - end - - describe 'GET #edit' do - it 'assigns the requested synapse as @synapse' do - synapse = Synapse.create! valid_attributes - get :edit, { id: synapse.to_param }, valid_session + get :show, { id: synapse.to_param, format: :json } expect(assigns(:synapse)).to eq(synapse) end end @@ -62,32 +18,28 @@ RSpec.describe SynapsesController, type: :controller do describe 'POST #create' do context 'with valid params' do it 'creates a new Synapse' do + synapse.reload # ensure it's present expect do - post :create, { synapse: valid_attributes }, valid_session + post :create, { synapse: valid_attributes, format: :json } end.to change(Synapse, :count).by(1) end it 'assigns a newly created synapse as @synapse' do - post :create, { synapse: valid_attributes }, valid_session + post :create, { synapse: valid_attributes, format: :json } expect(assigns(:synapse)).to be_a(Synapse) expect(assigns(:synapse)).to be_persisted end - it 'redirects to the created synapse' do - post :create, { synapse: valid_attributes }, valid_session - expect(response).to redirect_to(Synapse.last) + it 'returns 201 CREATED' do + post :create, { synapse: valid_attributes, format: :json } + expect(response.status).to eq 201 end end context 'with invalid params' do - it 'assigns a newly created but unsaved synapse as @synapse' do - post :create, { synapse: invalid_attributes }, valid_session - expect(assigns(:synapse)).to be_a_new(Synapse) - end - - it "re-renders the 'new' template" do - post :create, { synapse: invalid_attributes }, valid_session - expect(response).to render_template('new') + it 'returns 422 UNPROCESSABLE ENTITY' do + post :create, { synapse: invalid_attributes, format: :json } + expect(response.status).to eq 422 end end end @@ -95,66 +47,49 @@ RSpec.describe SynapsesController, type: :controller do describe 'PUT #update' do context 'with valid params' do let(:new_attributes) do - skip('Add a hash of attributes valid for your model') + { desc: 'My new description', + category: 'both', + permission: :public } end it 'updates the requested synapse' do - synapse = Synapse.create! valid_attributes put :update, - { id: synapse.to_param, synapse: new_attributes }, - valid_session + { id: synapse.to_param, synapse: new_attributes, format: :json } synapse.reload - skip('Add assertions for updated state') + expect(synapse.desc).to eq 'My new description' + expect(synapse.category).to eq 'both' + expect(synapse.permission).to eq 'public' end - it 'assigns the requested synapse as @synapse' do - synapse = Synapse.create! valid_attributes + it 'returns 204 NO CONTENT' do put :update, - { id: synapse.to_param, synapse: valid_attributes }, - valid_session - expect(assigns(:synapse)).to eq(synapse) - end - - it 'redirects to the synapse' do - synapse = Synapse.create! valid_attributes - put :update, - { id: synapse.to_param, synapse: valid_attributes }, - valid_session - expect(response).to redirect_to(synapse) + { id: synapse.to_param, synapse: valid_attributes, format: :json } + expect(response.status).to eq 204 end end context 'with invalid params' do it 'assigns the synapse as @synapse' do - synapse = Synapse.create! valid_attributes put :update, - { id: synapse.to_param, synapse: invalid_attributes }, - valid_session + { id: synapse.to_param, synapse: invalid_attributes, format: :json } expect(assigns(:synapse)).to eq(synapse) end - - it "re-renders the 'edit' template" do - synapse = Synapse.create! valid_attributes - put :update, - { id: synapse.to_param, synapse: invalid_attributes }, - valid_session - expect(response).to render_template('edit') - end end end describe 'DELETE #destroy' do + let(:synapse) { create(:synapse, user: controller.current_user) } + it 'destroys the requested synapse' do - synapse = Synapse.create! valid_attributes + synapse.reload # ensure it's present expect do - delete :destroy, { id: synapse.to_param }, valid_session + delete :destroy, { id: synapse.to_param, format: :json } end.to change(Synapse, :count).by(-1) end - it 'redirects to the synapses list' do - synapse = Synapse.create! valid_attributes - delete :destroy, { id: synapse.to_param }, valid_session - expect(response).to redirect_to(synapses_url) + it 'returns 204 NO CONTENT' do + delete :destroy, { id: synapse.to_param, format: :json } + expect(response.status).to eq 204 end end end diff --git a/spec/controllers/topics_controller_spec.rb b/spec/controllers/topics_controller_spec.rb index 35b2156e..2fc99b22 100644 --- a/spec/controllers/topics_controller_spec.rb +++ b/spec/controllers/topics_controller_spec.rb @@ -1,36 +1,16 @@ require 'rails_helper' RSpec.describe TopicsController, type: :controller do - let(:valid_attributes) do - skip('Add a hash of attributes valid for your model') - end - - let(:invalid_attributes) do - skip('Add a hash of attributes invalid for your model') - end - - let(:valid_session) { {} } - - describe 'GET #index' do - it 'assigns all topics as @topics' do - topic = Topic.create! valid_attributes - get :index, {}, valid_session - expect(assigns(:topics)).to eq([topic]) - end + let(:topic) { create(:topic) } + let(:valid_attributes) { topic.attributes.except('id') } + let(:invalid_attributes) { { permission: :invalid_lol } } + before :each do + sign_in end describe 'GET #show' do it 'assigns the requested topic as @topic' do - topic = Topic.create! valid_attributes - get :show, { id: topic.to_param }, valid_session - expect(assigns(:topic)).to eq(topic) - end - end - - describe 'GET #edit' do - it 'assigns the requested topic as @topic' do - topic = Topic.create! valid_attributes - get :edit, { id: topic.to_param }, valid_session + get :show, { id: topic.to_param, format: :json } expect(assigns(:topic)).to eq(topic) end end @@ -38,99 +18,82 @@ RSpec.describe TopicsController, type: :controller do describe 'POST #create' do context 'with valid params' do it 'creates a new Topic' do + topic.reload # ensure it's created expect do - post :create, { topic: valid_attributes }, valid_session + post :create, { topic: valid_attributes, format: :json } end.to change(Topic, :count).by(1) end it 'assigns a newly created topic as @topic' do - post :create, { topic: valid_attributes }, valid_session + post :create, { topic: valid_attributes, format: :json } expect(assigns(:topic)).to be_a(Topic) expect(assigns(:topic)).to be_persisted end - it 'redirects to the created topic' do - post :create, { topic: valid_attributes }, valid_session - expect(response).to redirect_to(Topic.last) + it 'returns 201 CREATED' do + post :create, { topic: valid_attributes, format: :json } + expect(response.status).to eq 201 end end context 'with invalid params' do it 'assigns a newly created but unsaved topic as @topic' do - post :create, { topic: invalid_attributes }, valid_session + post :create, { topic: invalid_attributes, format: :json } expect(assigns(:topic)).to be_a_new(Topic) end - - it "re-renders the 'new' template" do - post :create, { topic: invalid_attributes }, valid_session - expect(response).to render_template('new') - end end end describe 'PUT #update' do context 'with valid params' do let(:new_attributes) do - skip('Add a hash of attributes valid for your model') + { name: 'Cool Topic with no number', + desc: 'This is a cool topic.', + link: 'https://cool-topics.com/4', + permission: :public } end it 'updates the requested topic' do - topic = Topic.create! valid_attributes put :update, - { id: topic.to_param, topic: new_attributes }, - valid_session + { id: topic.to_param, topic: new_attributes, format: :json } topic.reload - skip('Add assertions for updated state') + expect(topic.name).to eq 'Cool Topic with no number' + expect(topic.desc).to eq 'This is a cool topic.' + expect(topic.link).to eq 'https://cool-topics.com/4' + expect(topic.permission).to eq 'public' end it 'assigns the requested topic as @topic' do - topic = Topic.create! valid_attributes put :update, - { id: topic.to_param, topic: valid_attributes }, - valid_session + { id: topic.to_param, topic: valid_attributes, format: :json } expect(assigns(:topic)).to eq(topic) end - it 'redirects to the topic' do - topic = Topic.create! valid_attributes + it 'returns status of no content' do put :update, - { id: topic.to_param, topic: valid_attributes }, - valid_session - expect(response).to redirect_to(topic) + { id: topic.to_param, topic: valid_attributes, format: :json } + expect(response.status).to eq 204 end end context 'with invalid params' do it 'assigns the topic as @topic' do - topic = Topic.create! valid_attributes put :update, - { id: topic.to_param, topic: invalid_attributes }, - valid_session + { id: topic.to_param, topic: invalid_attributes, format: :json } expect(assigns(:topic)).to eq(topic) end - - it "re-renders the 'edit' template" do - topic = Topic.create! valid_attributes - put :update, - { id: topic.to_param, topic: invalid_attributes }, - valid_session - expect(response).to render_template('edit') - end end end describe 'DELETE #destroy' do + let(:owned_topic) { create(:topic, user: controller.current_user) } it 'destroys the requested topic' do - topic = Topic.create! valid_attributes + owned_topic.reload # ensure it's there expect do - delete :destroy, { id: topic.to_param }, valid_session + delete :destroy, { id: owned_topic.to_param, format: :json } end.to change(Topic, :count).by(-1) - end - - it 'redirects to the topics list' do - topic = Topic.create! valid_attributes - delete :destroy, { id: topic.to_param }, valid_session - expect(response).to redirect_to(topics_url) + expect(response.body).to eq '' + expect(response.status).to eq 204 end end end diff --git a/spec/factories/mappings.rb b/spec/factories/mappings.rb new file mode 100644 index 00000000..bed0b754 --- /dev/null +++ b/spec/factories/mappings.rb @@ -0,0 +1,14 @@ +FactoryGirl.define do + factory :mapping do + xloc 0 + yloc 0 + map + user + association :mappable, factory: :topic + + factory :mapping_random_location do + xloc { rand(-100...100) } + yloc { rand(-100...100) } + end + end +end diff --git a/spec/factories/metacodes.rb b/spec/factories/metacodes.rb index 5cfb38f6..543e4955 100644 --- a/spec/factories/metacodes.rb +++ b/spec/factories/metacodes.rb @@ -1,4 +1,8 @@ FactoryGirl.define do factory :metacode do + sequence(:name) { |n| "Cool Metacode ##{n}" } + manual_icon 'https://images.com/image.png' + aws_icon nil + color '#cccccc' end end diff --git a/spec/factories/synapses.rb b/spec/factories/synapses.rb index b83a0073..4454a7a4 100644 --- a/spec/factories/synapses.rb +++ b/spec/factories/synapses.rb @@ -1,9 +1,10 @@ FactoryGirl.define do factory :synapse do sequence(:desc) { |n| "Cool synapse ##{n}" } - category :to + category :'from-to' permission :commons association :topic1, factory: :topic association :topic2, factory: :topic + user end end diff --git a/spec/models/map_spec.rb b/spec/models/map_spec.rb index d1486192..27227188 100644 --- a/spec/models/map_spec.rb +++ b/spec/models/map_spec.rb @@ -5,34 +5,5 @@ RSpec.describe Map, type: :model do it { is_expected.to validate_presence_of :name } it { is_expected.to validate_presence_of :permission } it { is_expected.to validate_inclusion_of(:permission).in_array Perm::ISSIONS.map(&:to_s) } - - context 'permissions' do - let(:owner) { create :user } - let(:other_user) { create :user } - let(:map) { create :map, user: owner, permission: :commons } - let(:private_map) { create :map, user: owner, permission: :private } - let(:public_map) { create :map, user: owner, permission: :public } - - it 'prevents deletion by non-owner' do - expect(map.authorize_to_delete(other_user)).to eq false - expect(map.authorize_to_delete(owner)).to eq map - end - - it 'prevents visibility if private' do - expect(map.authorize_to_show(other_user)).to eq map - expect(map.authorize_to_show(owner)).to eq map - expect(private_map.authorize_to_show(owner)).to eq private_map - expect(private_map.authorize_to_show(other_user)).to eq false - end - - it 'only allows editing if commons or owned' do - expect(map.authorize_to_edit(other_user)).to eq map - expect(map.authorize_to_edit(owner)).to eq map - expect(private_map.authorize_to_edit(other_user)).to eq false - expect(private_map.authorize_to_edit(owner)).to eq private_map - expect(public_map.authorize_to_edit(other_user)).to eq false - expect(public_map.authorize_to_edit(owner)).to eq public_map - end - end end diff --git a/spec/models/mapping_spec.rb b/spec/models/mapping_spec.rb index 32d34796..54c72b88 100644 --- a/spec/models/mapping_spec.rb +++ b/spec/models/mapping_spec.rb @@ -1,5 +1,11 @@ require 'rails_helper' RSpec.describe Mapping, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + it { is_expected.to belong_to :user } + it { is_expected.to belong_to :map } + it { is_expected.to belong_to :mappable } + it { is_expected.to validate_presence_of :xloc } + it { is_expected.to validate_presence_of :yloc } + it { is_expected.to validate_presence_of :map } + it { is_expected.to validate_presence_of :mappable } end diff --git a/spec/models/metacode_spec.rb b/spec/models/metacode_spec.rb index 10571a81..ad0b6ced 100644 --- a/spec/models/metacode_spec.rb +++ b/spec/models/metacode_spec.rb @@ -1,6 +1,30 @@ require 'rails_helper' RSpec.describe Metacode, type: :model do - pending "add some examples to (or delete) #{__FILE__}" - it { is_expected.to have_many(:topics) } + it { is_expected.to have_many :topics } + it { is_expected.to have_many :metacode_sets } + + context 'BOTH aws_icon and manual_icon' do + let(:icon) { File.open(Rails.root.join('app', 'assets', 'images', + 'user.png')) } + let(:metacode) { build(:metacode, aws_icon: icon, + manual_icon: 'https://metamaps.cc/assets/user.png') } + it 'raises a validation error' do + expect { metacode.save! }.to raise_error ActiveRecord::RecordInvalid + end + end + + context 'NEITHER aws_icon or manual_icon' do + let(:metacode) { build(:metacode, aws_icon: nil, manual_icon: nil) } + it 'raises a validation error' do + expect { metacode.save! }.to raise_error ActiveRecord::RecordInvalid + end + end + + context 'non-https manual icon' do + let(:metacode) { build(:metacode, manual_icon: 'http://metamaps.cc/assets/user.png') } + it 'raises a validation error' do + expect { metacode.save! }.to raise_error ActiveRecord::RecordInvalid + end + end end diff --git a/spec/models/synapse_spec.rb b/spec/models/synapse_spec.rb index a1069805..6ba5ff22 100644 --- a/spec/models/synapse_spec.rb +++ b/spec/models/synapse_spec.rb @@ -8,34 +8,6 @@ RSpec.describe Synapse, type: :model do it { is_expected.to have_many :mappings } it { is_expected.to validate_presence_of :permission } it { is_expected.to validate_inclusion_of(:permission).in_array Perm::ISSIONS.map(&:to_s) } + it { is_expected.to validate_inclusion_of(:category).in_array ['from-to', 'both'] } it { is_expected.to validate_length_of(:desc).is_at_least(0) } # TODO don't allow nil - - context 'permissions' do - let(:owner) { create :user } - let(:other_user) { create :user } - let(:synapse) { create :synapse, user: owner, permission: :commons } - let(:private_synapse) { create :synapse, user: owner, permission: :private } - let(:public_synapse) { create :synapse, user: owner, permission: :public } - - it 'prevents deletion by non-owner' do - expect(synapse.authorize_to_delete(other_user)).to eq false - expect(synapse.authorize_to_delete(owner)).to eq synapse - end - - it 'prevents visibility if private' do - expect(synapse.authorize_to_show(other_user)).to eq synapse - expect(synapse.authorize_to_show(owner)).to eq synapse - expect(private_synapse.authorize_to_show(owner)).to eq private_synapse - expect(private_synapse.authorize_to_show(other_user)).to eq false - end - - it 'only allows editing if commons or owned' do - expect(synapse.authorize_to_edit(other_user)).to eq synapse - expect(synapse.authorize_to_edit(owner)).to eq synapse - expect(private_synapse.authorize_to_edit(other_user)).to eq false - expect(private_synapse.authorize_to_edit(owner)).to eq private_synapse - expect(public_synapse.authorize_to_edit(other_user)).to eq false - expect(public_synapse.authorize_to_edit(owner)).to eq public_synapse - end - end end diff --git a/spec/models/token_spec.rb b/spec/models/token_spec.rb new file mode 100644 index 00000000..ddb8d696 --- /dev/null +++ b/spec/models/token_spec.rb @@ -0,0 +1,10 @@ +require 'rails_helper' + +RSpec.describe Token, type: :model do + context "#generate_token" do + subject (:token) { Token.new } + it "should generate an alphanumeric token of 32 characters" do + expect(token.send(:generate_token)).to match /^[a-zA-Z0-9]{32}$/ + end + end +end diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb index b499daac..dbaac86d 100644 --- a/spec/models/topic_spec.rb +++ b/spec/models/topic_spec.rb @@ -7,75 +7,4 @@ RSpec.describe Topic, type: :model do it { is_expected.to have_many :mappings } it { is_expected.to validate_presence_of :permission } it { is_expected.to validate_inclusion_of(:permission).in_array Perm::ISSIONS.map(&:to_s) } - - context 'has_viewable_synapses function' do - let (:user) { create(:user) } - let (:other_user) { create(:user) } - - context 'topic with no synapses' do - let (:topic) { create(:topic) } - - it 'returns false' do - expect(topic.has_viewable_synapses(user)).to eq false - end - end - - context 'topic with one unpermitted synapse' do - let (:synapse) { create(:synapse, permission: :private, user: other_user) } - let (:topic) { create(:topic, synapses1: [synapse]) } - - it 'returns false' do - expect(topic.has_viewable_synapses(user)).to eq false - end - end - - context 'topic with one permitted synapse' do - let (:synapse) { create(:synapse, permission: :private, user: user) } - let(:topic) { create(:topic, synapses1: [synapse]) } - - it 'returns true' do - expect(topic.has_viewable_synapses(user)).to eq true - end - end - - context 'topic with one unpermitted, one permitted synapse' do - let (:synapse1) { create(:synapse, permission: :private, user: other_user) } - let (:synapse2) { create(:synapse, permission: :private, user: user) } - let (:topic) { create(:topic, synapses1: [synapse1, synapse2]) } - - it 'returns true' do - expect(topic.synapses.count).to eq 2 - expect(topic.has_viewable_synapses(user)).to eq true - end - end - end - - context 'permssions' do - let(:owner) { create :user } - let(:other_user) { create :user } - let(:topic) { create :topic, user: owner, permission: :commons } - let(:private_topic) { create :topic, user: owner, permission: :private } - let(:public_topic) { create :topic, user: owner, permission: :public } - - it 'prevents deletion by non-owner' do - expect(topic.authorize_to_delete(other_user)).to eq false - expect(topic.authorize_to_delete(owner)).to eq topic - end - - it 'prevents visibility if private' do - expect(topic.authorize_to_show(other_user)).to eq topic - expect(topic.authorize_to_show(owner)).to eq topic - expect(private_topic.authorize_to_show(owner)).to eq private_topic - expect(private_topic.authorize_to_show(other_user)).to eq false - end - - it 'only allows editing if commons or owned' do - expect(topic.authorize_to_edit(other_user)).to eq topic - expect(topic.authorize_to_edit(owner)).to eq topic - expect(private_topic.authorize_to_edit(other_user)).to eq false - expect(private_topic.authorize_to_edit(owner)).to eq private_topic - expect(public_topic.authorize_to_edit(other_user)).to eq false - expect(public_topic.authorize_to_edit(owner)).to eq public_topic - end - end end diff --git a/spec/policies/map_policy_spec.rb b/spec/policies/map_policy_spec.rb new file mode 100644 index 00000000..7dd33707 --- /dev/null +++ b/spec/policies/map_policy_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe MapPolicy, type: :policy do + subject { described_class } + + context 'unauthenticated' do + context 'commons' do + let(:map) { create(:map, permission: :commons) } + permissions :show? do + it 'permits access' do + expect(subject).to permit(nil, map) + end + end + permissions :create?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(nil, map) + end + end + end + + context 'private' do + let(:map) { create(:map, permission: :private) } + permissions :show?, :create?, :update?, :destroy? do + it 'permits access' do + expect(subject).to_not permit(nil, map) + end + end + end + end + + # + # Now begin the logged-in tests + # + + context 'logged in' do + let(:user) { create(:user) } + + context 'commons' do + let(:owner) { create(:user) } + let(:map) { create(:map, permission: :commons, user: owner) } + permissions :show?, :create?, :update? do + it 'permits access' do + expect(subject).to permit(user, map) + end + end + permissions :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, map) + end + it 'permits access to owner' do + expect(subject).to permit(owner, map) + end + end + end + + context 'public' do + let(:owner) { create(:user) } + let(:map) { create(:map, permission: :public, user: owner) } + permissions :show?, :create? do + it 'permits access' do + expect(subject).to permit(user, map) + end + end + permissions :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, map) + end + it 'permits access to owner' do + expect(subject).to permit(owner, map) + end + end + end + + context 'private' do + let(:owner) { create(:user) } + let(:map) { create(:map, permission: :private, user: owner) } + permissions :create? do + it 'permits access' do + expect(subject).to permit(user, map) + end + end + permissions :show?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, map) + end + it 'permits access to owner' do + expect(subject).to permit(owner, map) + end + end + end + end +end diff --git a/spec/policies/mapping_policy_spec.rb b/spec/policies/mapping_policy_spec.rb new file mode 100644 index 00000000..291a9c17 --- /dev/null +++ b/spec/policies/mapping_policy_spec.rb @@ -0,0 +1,7 @@ +require 'rails_helper' + +RSpec.describe MappingPolicy, type: :policy do + subject { described_class } + + pending 'Implement some mapping tests!' +end diff --git a/spec/policies/synapse_policy.rb b/spec/policies/synapse_policy.rb new file mode 100644 index 00000000..4c725e37 --- /dev/null +++ b/spec/policies/synapse_policy.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe SynapsePolicy, type: :policy do + subject { described_class } + + context 'unauthenticated' do + context 'commons' do + let(:synapse) { create(:synapse, permission: :commons) } + permissions :show? do + it 'permits access' do + expect(subject).to permit(nil, synapse) + end + end + permissions :create?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(nil, synapse) + end + end + end + + context 'private' do + let(:synapse) { create(:synapse, permission: :private) } + permissions :show?, :create?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(nil, synapse) + end + end + end + end + + # + # Now begin the logged-in tests + # + + context 'logged in' do + let(:user) { create(:user) } + + context 'commons' do + let(:owner) { create(:user) } + let(:synapse) { create(:synapse, permission: :commons, user: owner) } + permissions :show?, :create?, :update? do + it 'permits access' do + expect(subject).to permit(user, synapse) + end + end + permissions :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, synapse) + end + it 'permits access to owner' do + expect(subject).to permit(owner, synapse) + end + end + end + + context 'public' do + let(:owner) { create(:user) } + let(:synapse) { create(:synapse, permission: :public, user: owner) } + permissions :show?, :create? do + it 'permits access' do + expect(subject).to permit(user, synapse) + end + end + permissions :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, synapse) + end + it 'permits access to owner' do + expect(subject).to permit(owner, synapse) + end + end + end + + context 'private' do + let(:owner) { create(:user) } + let(:synapse) { create(:synapse, permission: :private, user: owner) } + permissions :create? do + it 'permits access' do + expect(subject).to permit(user, synapse) + end + end + permissions :show?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, synapse) + end + it 'permits access to owner' do + expect(subject).to permit(owner, synapse) + end + end + end + end +end diff --git a/spec/policies/topic_policy_spec.rb b/spec/policies/topic_policy_spec.rb new file mode 100644 index 00000000..7078496c --- /dev/null +++ b/spec/policies/topic_policy_spec.rb @@ -0,0 +1,92 @@ +require 'rails_helper' + +RSpec.describe TopicPolicy, type: :policy do + subject { described_class } + + context 'unauthenticated' do + context 'commons' do + let(:topic) { create(:topic, permission: :commons) } + permissions :show? do + it 'permits access' do + expect(subject).to permit(nil, topic) + end + end + permissions :create?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(nil, topic) + end + end + end + + context 'private' do + let(:topic) { create(:topic, permission: :private) } + permissions :show?, :create?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(nil, topic) + end + end + end + end + + # + # Now begin the logged-in tests + # + + context 'logged in' do + let(:user) { create(:user) } + + context 'commons' do + let(:owner) { create(:user) } + let(:topic) { create(:topic, permission: :commons, user: owner) } + permissions :show?, :create?, :update? do + it 'permits access' do + expect(subject).to permit(user, topic) + end + end + permissions :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, topic) + end + it 'permits access to owner' do + expect(subject).to permit(owner, topic) + end + end + end + + context 'public' do + let(:owner) { create(:user) } + let(:topic) { create(:topic, permission: :public, user: owner) } + permissions :show?, :create? do + it 'permits access' do + expect(subject).to permit(user, topic) + end + end + permissions :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, topic) + end + it 'permits access to owner' do + expect(subject).to permit(owner, topic) + end + end + end + + context 'private' do + let(:owner) { create(:user) } + let(:topic) { create(:topic, permission: :private, user: owner) } + permissions :create? do + it 'permits access' do + expect(subject).to permit(user, topic) + end + end + permissions :show?, :update?, :destroy? do + it 'denies access' do + expect(subject).to_not permit(user, topic) + end + it 'permits access to owner' do + expect(subject).to permit(owner, topic) + end + end + end + end +end diff --git a/spec/schemas/map.json b/spec/schemas/map.json new file mode 100644 index 00000000..e69de29b diff --git a/spec/schemas/map_contains.json b/spec/schemas/map_contains.json new file mode 100644 index 00000000..0b4faebf --- /dev/null +++ b/spec/schemas/map_contains.json @@ -0,0 +1,42 @@ +{ + "name": "Map Contents", + "type": "object", + "properties": { + "map": { + "type": "object" + }, + "topics": { + "type": "array", + "items": { + "type": "object" + } + }, + "synapses": { + "type": "array", + "items": { + "type": "object" + } + }, + "mappings": { + "type": "array", + "items": { + "type": "object" + } + }, + "mappers": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "map", + "topics", + "synapses", + "mappings", + "mappers" + ] +} + + diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index a2b164b2..d4028602 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,7 @@ +require 'simplecov' +require 'support/controller_helpers' +require 'pundit/rspec' + RSpec.configure do |config| config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true diff --git a/spec/support/controller_helpers.rb b/spec/support/controller_helpers.rb index 5fe34854..ed0d137a 100644 --- a/spec/support/controller_helpers.rb +++ b/spec/support/controller_helpers.rb @@ -1,4 +1,7 @@ # https://github.com/plataformatec/devise/wiki/How-To:-Stub-authentication-in-controller-specs + +require 'devise' + module ControllerHelpers # rubocop:disable Metrics/AbcSize def sign_in(user = create(:user)) @@ -7,6 +10,7 @@ module ControllerHelpers receive(:authenticate!).and_throw(:warden, scope: :user) ) else # simulate authenticated + allow_message_expectations_on_nil allow(request.env['warden']).to( receive(:authenticate!).and_return(user) ) @@ -15,3 +19,8 @@ module ControllerHelpers end # rubocop:enable Metrics/AbcSize end + +RSpec.configure do |config| + config.include Devise::TestHelpers, :type => :controller + config.include ControllerHelpers, :type => :controller +end diff --git a/spec/support/schema_matcher.rb b/spec/support/schema_matcher.rb new file mode 100644 index 00000000..b2a89352 --- /dev/null +++ b/spec/support/schema_matcher.rb @@ -0,0 +1,7 @@ +RSpec::Matchers.define :match_json_schema do |schema| + match do |json| + schema_directory = Rails.root.join('spec', 'schemas').to_s + schema_path = "#{schema_directory}/#{schema}.json" + JSON::Validator.validate!(schema_path, json) + end +end